Repository: evcc-io/evcc
Pull request #12841 (2024-03-10 11:32:24)

Add Retryable feature to indicate vehicles that are temporarily offline
Goal of this PR is to make `minsoc`/`limitsoc` editable for broken vehicles. To avoid an intermediate step, we're combining this with moving the vehicle settings from arrival tab to config ui.

Fix https://github.com/evcc-io/evcc/issues/12460.
 This is an intermediate step. Full solution depends on https://github.com/evcc-io/evcc/pull/11572.

**Changes:**
- ☁️ show unreachable icon instead of "$title (unavailable)"-text. Added tooltip and link to restart dialog.
- ♻️ fixed tooltip and rotation animation for autodetect state (new icon, no wobbling)

**fixed autodetect animation**

https://github.com/evcc-io/evcc/assets/152287/e39101d6-961f-4833-a970-bf5da6b5f5e4

**new unreachable icon**

https://github.com/evcc-io/evcc/assets/152287/17ca6ec4-c359-4605-9f6d-46c39e7e4202

TODO

- [ ] add features to readable vehicle config (separate key) @andig
- [ ] move vehicle settings (arrival tab) to config ui (part of https://github.com/evcc-io/evcc/issues/12903) @naltatis 
- [ ] allow minsoc/phases manupulation even though vehicle is `offline` @naltatis 


Patch:
diff --git a/README.md b/README.md
index ce434c30..4af0c6e1 100644
--- a/README.md
+++ b/README.md
@@ -97,7 +97,7 @@ make ui build
 
 ### Code formatting
 
-We use linters (golangci-lint, Prettier) to keep a coherent source code formatting. It's recommended to use the format-on-save feature of your editor. For VSCode use the [Go](https://marketplace.visualstudio.com/items?itemName=golang.Go), [Prettier](https://marketplace.visualstudio.com/items?itemName=esbenp.prettier-vscode) and [Veture](https://marketplace.visualstudio.com/items?itemName=octref.vetur) extension. You can manually reformat your code by running:
+We use linters (golangci-lint, Prettier) to keep a coherent source code formatting. It's recommended to use the format-on-save feature of your editor. For VSCode use the [Go](https://marketplace.visualstudio.com/items?itemName=golang.Go), [Prettier](https://marketplace.visualstudio.com/items?itemName=esbenp.prettier-vscode) and [Vetur](https://marketplace.visualstudio.com/items?itemName=octref.vetur) extension. You can manually reformat your code by running:
 
 ```sh
 make lint
diff --git a/api/feature.go b/api/feature.go
index 1621b5d7..179ef0b5 100644
--- a/api/feature.go
+++ b/api/feature.go
@@ -17,4 +17,5 @@ const (
 	CoarseCurrent
 	IntegratedDevice
 	Heating
+	Retryable
 )
diff --git a/api/feature_enumer.go b/api/feature_enumer.go
index faaa1c60..8636adf6 100644
--- a/api/feature_enumer.go
+++ b/api/feature_enumer.go
@@ -7,11 +7,11 @@ import (
 	"strings"
 )
 
-const _FeatureName = "OfflineCoarseCurrentIntegratedDeviceHeating"
+const _FeatureName = "OfflineCoarseCurrentIntegratedDeviceHeatingRetryable"
 
-var _FeatureIndex = [...]uint8{0, 7, 20, 36, 43}
+var _FeatureIndex = [...]uint8{0, 7, 20, 36, 43, 52}
 
-const _FeatureLowerName = "offlinecoarsecurrentintegrateddeviceheating"
+const _FeatureLowerName = "offlinecoarsecurrentintegrateddeviceheatingretryable"
 
 func (i Feature) String() string {
 	i -= 1
@@ -29,9 +29,10 @@ func _FeatureNoOp() {
 	_ = x[CoarseCurrent-(2)]
 	_ = x[IntegratedDevice-(3)]
 	_ = x[Heating-(4)]
+	_ = x[Retryable-(5)]
 }
 
-var _FeatureValues = []Feature{Offline, CoarseCurrent, IntegratedDevice, Heating}
+var _FeatureValues = []Feature{Offline, CoarseCurrent, IntegratedDevice, Heating, Retryable}
 
 var _FeatureNameToValueMap = map[string]Feature{
 	_FeatureName[0:7]:        Offline,
@@ -42,6 +43,8 @@ var _FeatureNameToValueMap = map[string]Feature{
 	_FeatureLowerName[20:36]: IntegratedDevice,
 	_FeatureName[36:43]:      Heating,
 	_FeatureLowerName[36:43]: Heating,
+	_FeatureName[43:52]:      Retryable,
+	_FeatureLowerName[43:52]: Retryable,
 }
 
 var _FeatureNames = []string{
@@ -49,6 +52,7 @@ var _FeatureNames = []string{
 	_FeatureName[7:20],
 	_FeatureName[20:36],
 	_FeatureName[36:43],
+	_FeatureName[43:52],
 }
 
 // FeatureString retrieves an enum value from the enum constants string name.
diff --git a/assets/css/app.css b/assets/css/app.css
index 1e923c6d..05c212c2 100644
--- a/assets/css/app.css
+++ b/assets/css/app.css
@@ -508,3 +508,10 @@ html.app .modal-dialog {
 	margin-top: env(safe-area-inset-top);
 	margin-bottom: env(safe-area-inset-bottom);
 }
+
+.btn-neutral {
+	all: unset;
+}
+.btn-neutral:hover {
+	outline: revert;
+}
diff --git a/assets/js/components/Loadpoint.vue b/assets/js/components/Loadpoint.vue
index 1a785847..176d00f8 100644
--- a/assets/js/components/Loadpoint.vue
+++ b/assets/js/components/Loadpoint.vue
@@ -236,6 +236,11 @@ export default {
 		vehicleHasSoc: function () {
 			return this.vehicleKnown && !this.vehicle?.features?.includes("Offline");
 		},
+		vehicleNotReachable: function () {
+			// online vehicle that was not reachable at startup
+			const features = this.vehicle?.features || [];
+			return features.includes("Offline") && features.includes("Retryable");
+		},
 		socBasedCharging: function () {
 			return this.vehicleHasSoc || this.vehicleSoc > 0;
 		},
diff --git a/assets/js/components/MaterialIcon/CloudOffline.vue b/assets/js/components/MaterialIcon/CloudOffline.vue
new file mode 100644
index 00000000..75dbc8a6
--- /dev/null
+++ b/assets/js/components/MaterialIcon/CloudOffline.vue
@@ -0,0 +1,17 @@
+<template>
+	<svg :style="svgStyle" viewBox="0 0 24 24">
+		<path
+			fill="currentColor"
+			d="M6.5 20q-2.3 0-3.9-1.6T1 14.5q0-1.925 1.188-3.425T5.25 9.15q.075-.2.15-.387t.15-.413L2.1 4.9q-.275-.275-.275-.7t.275-.7q.275-.275.7-.275t.7.275l17 17q.275.275.288.688t-.288.712q-.275.275-.687.288t-.713-.263L17.15 20zm0-2h8.65L7.1 9.95q-.05.275-.075.525T7 11h-.5q-1.45 0-2.475 1.025T3 14.5q0 1.45 1.025 2.475T6.5 18m15.1.75l-1.45-1.4q.425-.35.638-.812T21 15.5q0-1.05-.725-1.775T18.5 13H17v-2q0-2.075-1.463-3.537T12 6q-.675 0-1.3.163t-1.2.512l-1.45-1.45q.875-.6 1.863-.912T12 4q2.925 0 4.963 2.038T19 11q1.725.2 2.863 1.488T23 15.5q0 .975-.375 1.813T21.6 18.75m-6.775-6.725"
+		></path>
+	</svg>
+</template>
+
+<script>
+import icon from "../../mixins/icon";
+
+export default {
+	name: "CloudOffline",
+	mixins: [icon],
+};
+</script>
diff --git a/assets/js/components/MaterialIcon/Sync.vue b/assets/js/components/MaterialIcon/Sync.vue
new file mode 100644
index 00000000..7da9f2d1
--- /dev/null
+++ b/assets/js/components/MaterialIcon/Sync.vue
@@ -0,0 +1,17 @@
+<template>
+	<svg :style="svgStyle" viewBox="0 0 24 24">
+		<path
+			fill="currentColor"
+			d="M6 12.05q0 1.125.425 2.188T7.75 16.2l.25.25V15q0-.425.288-.712T9 14q.425 0 .713.288T10 15v4q0 .425-.288.713T9 20H5q-.425 0-.712-.288T4 19q0-.425.288-.712T5 18h1.75l-.4-.35q-1.3-1.15-1.825-2.625T4 12.05Q4 9.7 5.2 7.787T8.425 4.85q.35-.2.738-.025t.512.575q.125.375-.012.75t-.488.575q-1.45.8-2.312 2.213T6 12.05m12-.1q0-1.125-.425-2.187T16.25 7.8L16 7.55V9q0 .425-.288.713T15 10q-.425 0-.712-.288T14 9V5q0-.425.288-.712T15 4h4q.425 0 .713.288T20 5q0 .425-.288.713T19 6h-1.75l.4.35q1.225 1.225 1.788 2.663T20 11.95q0 2.35-1.2 4.263t-3.225 2.937q-.35.2-.737.025t-.513-.575q-.125-.375.013-.75t.487-.575q1.45-.8 2.313-2.212T18 11.95"
+		></path>
+	</svg>
+</template>
+
+<script>
+import icon from "../../mixins/icon";
+
+export default {
+	name: "Sync",
+	mixins: [icon],
+};
+</script>
diff --git a/assets/js/components/Vehicle.vue b/assets/js/components/Vehicle.vue
index 7a2f3001..31159efe 100644
--- a/assets/js/components/Vehicle.vue
+++ b/assets/js/components/Vehicle.vue
@@ -123,6 +123,7 @@ export default {
 		vehicles: Array,
 		vehicleSoc: Number,
 		vehicleTargetSoc: Number,
+		vehicleNotReachable: Boolean,
 	},
 	emits: ["limit-soc-updated", "limit-energy-updated", "change-vehicle", "remove-vehicle"],
 	data() {
diff --git a/assets/js/components/VehicleTitle.vue b/assets/js/components/VehicleTitle.vue
index e413ede3..e31a0990 100644
--- a/assets/js/components/VehicleTitle.vue
+++ b/assets/js/components/VehicleTitle.vue
@@ -1,13 +1,15 @@
 <template>
 	<div class="d-flex justify-content-between mb-3 align-items-center" data-testid="vehicle-title">
 		<h4 class="d-flex align-items-center m-0 flex-grow-1 overflow-hidden">
-			<shopicon-regular-refresh
+			<div
 				v-if="iconType === 'refresh'"
+				class="me-2 flex-shrink-0 spin"
 				ref="refresh"
-				data-bs-toggle="tooltip"
 				:title="$t('main.vehicle.detectionActive')"
-				class="me-2 flex-shrink-0 spin"
-			></shopicon-regular-refresh>
+				data-bs-toggle="tooltip"
+			>
+				<Sync />
+			</div>
 			<VehicleIcon
 				v-else-if="iconType === 'vehicle'"
 				:name="icon"
@@ -26,38 +28,59 @@
 				@change-vehicle="changeVehicle"
 				@remove-vehicle="removeVehicle"
 			>
-				<span class="flex-grow-1 text-truncate vehicle-name">
+				<span class="flex-grow-1 text-truncate vehicle-name" data-testid="vehicle-name">
 					{{ name }}
 				</span>
 			</VehicleOptions>
-			<span v-else class="flex-grow-1 text-truncate vehicle-name">
+			<span v-else class="flex-grow-1 text-truncate vehicle-name" data-testid="vehicle-name">
 				{{ name }}
 			</span>
+			<button
+				v-if="vehicleNotReachable"
+				class="ms-2 btn-neutral"
+				ref="notReachable"
+				data-bs-toggle="tooltip"
+				:title="$t('main.vehicle.notReachable')"
+				type="button"
+				data-testid="vehicle-not-reachable-icon"
+				@click="openHelpModal"
+			>
+				<CloudOffline class="evcc-gray" />
+			</button>
 		</h4>
 	</div>
 </template>
 
 <script>
-import "@h2d2/shopicons/es/regular/refresh";
 import "@h2d2/shopicons/es/regular/cablecharge";
 import Tooltip from "bootstrap/js/dist/tooltip";
+import Modal from "bootstrap/js/dist/modal";
 import VehicleIcon from "./VehicleIcon";
 import VehicleOptions from "./VehicleOptions.vue";
+import CloudOffline from "./MaterialIcon/CloudOffline.vue";
+import Sync from "./MaterialIcon/Sync.vue";
 import collector from "../mixins/collector";
 
 export default {
 	name: "VehicleTitle",
-	components: { VehicleOptions, VehicleIcon },
+	components: { VehicleOptions, VehicleIcon, Sync, CloudOffline },
 	mixins: [collector],
 	props: {
 		connected: Boolean,
 		id: [String, Number],
 		vehicleDetectionActive: Boolean,
+		vehicleNotReachable: Boolean,
 		icon: String,
 		vehicleName: String,
 		vehicles: { type: Array, default: () => [] },
 		title: String,
 	},
+	data: function () {
+		return {
+			refreshTooltip: null,
+			notReachableTooltip: null,
+		};
+	},
 	emits: ["change-vehicle", "remove-vehicle"],
 	computed: {
 		iconType() {
@@ -93,11 +116,11 @@ export default {
 	},
 	watch: {
 		iconType: function () {
-			this.tooltip();
+			this.initTooltip();
 		},
 	},
 	mounted: function () {
-		this.tooltip();
+		this.initTooltip();
 	},
 	methods: {
 		changeVehicle(name) {
@@ -106,21 +129,28 @@ export default {
 		removeVehicle() {
 			this.$emit("remove-vehicle");
 		},
-		tooltip() {
+		initTooltip() {
 			this.$nextTick(() => {
+				this.refreshTooltip?.dispose();
+				this.notReachableTooltip?.dispose();
 				if (this.$refs.refresh) {
-					new Tooltip(this.$refs.refresh);
+					this.refreshTooltip = new Tooltip(this.$refs.refresh);
+				}
+				if (this.$refs.notReachable) {
+					this.notReachableTooltip = new Tooltip(this.$refs.notReachable);
 				}
 			});
 		},
+		openHelpModal() {
+			const modal = Modal.getOrCreateInstance(document.getElementById("helpModal"));
+			modal.show();
+			this.initTooltip();
+		},
 	},
 };
 </script>
 
 <style scoped>
-.options {
-	margin-right: -0.75rem;
-}
 .vehicle-name {
 	text-decoration-color: var(--evcc-gray);
 }
@@ -130,16 +160,12 @@ export default {
 .spin {
 	animation: rotation 1s infinite cubic-bezier(0.37, 0, 0.63, 1);
 }
-.spin :deep(svg) {
-	/* workaround to fix the not perfectly centered shopicon. Remove once its fixed in @h2d2/shopicons */
-	transform: translateY(-0.7px);
-}
 @keyframes rotation {
 	from {
 		transform: rotate(0deg);
 	}
 	to {
-		transform: rotate(360deg);
+		transform: rotate(-360deg);
 	}
 }
 </style>
diff --git a/assets/js/mixins/icon.js b/assets/js/mixins/icon.js
new file mode 100644
index 00000000..8b997cab
--- /dev/null
+++ b/assets/js/mixins/icon.js
@@ -0,0 +1,22 @@
+export default {
+  props: {
+    size: {
+      type: String,
+      validator: function (value) {
+        return ["s", "m", "l", "xl"].includes(value);
+      },
+    },
+  },
+  computed: {
+    svgStyle() {
+      const sizes = {
+        s: "24px",
+        m: "32px",
+        l: "48px",
+        xl: "64px",
+      };
+      const size = sizes[this.size] || sizes.s;
+      return { display: "block", width: size, height: size };
+    },
+  },
+};
diff --git a/charger/ocpp.go b/charger/ocpp.go
index 9404c370..b98c6758 100644
--- a/charger/ocpp.go
+++ b/charger/ocpp.go
@@ -174,6 +174,9 @@ func NewOCPP(id string, connector int, idtag string,
 			meterInterval = 10 * time.Second
 		}
 	} else {
+		// fix timing issue in EVBox when switching OCPP protocol version
+		time.Sleep(time.Second)
+
 		err := ocpp.Instance().GetConfiguration(cp.ID(), func(resp *core.GetConfigurationConfirmation, err error) {
 			if err == nil {
 				// log unsupported configuration keys
@@ -335,8 +338,8 @@ func (c *OCPP) Enable(enable bool) (err error) {
 
 	if enable {
 		if txn > 0 {
-			// we have the transaction id, switch from 0A to last current
-			return c.updatePeriod(c.current)
+			// we have the transaction id, treat as enabled
+			return nil
 		}
 
 		err = ocpp.Instance().RemoteStartTransaction(c.conn.ChargePoint().ID(), func(resp *core.RemoteStartTransactionConfirmation, err error) {
@@ -350,25 +353,31 @@ func (c *OCPP) Enable(enable bool) (err error) {
 			request.ConnectorId = &connector
 			request.ChargingProfile = c.getTxChargingProfile(c.current, 0)
 		})
+	} else {
+		// if no transaction is running, the vehicle may have stopped it (which is ok) or an unknown transaction is running
+		if txn == 0 {
+			// we cannot tell if a transaction is really running, so we check the status
+			status, err := c.Status()
+			if err != nil {
+				return err
+			}
+			if status == api.StatusC {
+				return errors.New("cannot disable: unknown transaction running")
+			}
 
-		return c.wait(err, rc)
-	}
-
-	// if no transaction is running, the vehicle may have stopped it (which is ok) or an unknown transaction is running
-	if txn == 0 {
-		// we cannot tell if a transaction is really running, so we check the status
-		status, err := c.Status()
-		if err != nil {
-			return err
-		}
-		if status == api.StatusC {
-			return errors.New("cannot disable: unknown transaction running")
+			return nil
 		}
 
-		return nil
+		err = ocpp.Instance().RemoteStopTransaction(c.conn.ChargePoint().ID(), func(resp *core.RemoteStopTransactionConfirmation, err error) {
+			if err == nil && resp != nil && resp.Status != types.RemoteStartStopStatusAccepted {
+				err = errors.New(string(resp.Status))
+			}
+
+			rc <- err
+		}, txn)
 	}
 
-	return c.updatePeriod(0)
+	return c.wait(err, rc)
 }
 
 func (c *OCPP) setChargingProfile(profile *types.ChargingProfile) error {
diff --git a/charger/twc3.go b/charger/twc3.go
index e383950a..c590637d 100644
--- a/charger/twc3.go
+++ b/charger/twc3.go
@@ -16,10 +16,7 @@ import (
 
 // Twc3 is an api.Vehicle implementation for Twc3 cars
 type Twc3 struct {
-	*request.Helper
-	log     *util.Logger
 	lp      loadpoint.API
-	uri     string
 	vitalsG func() (Vitals, error)
 	enabled bool
 }
@@ -71,20 +68,16 @@ func NewTwc3FromConfig(other map[string]interface{}) (api.Charger, error) {
 		return nil, err
 	}
 
-	log := util.NewLogger("twc3")
+	c := &Twc3{}
 
-	c := &Twc3{
-		Helper: request.NewHelper(log),
-		uri:    util.DefaultScheme(strings.TrimSuffix(cc.URI, "/"), "http"),
-		log:    log,
-	}
+	client := request.NewHelper(util.NewLogger("twc3"))
+	uri := fmt.Sprintf("%s/api/1/vitals", util.DefaultScheme(strings.TrimSuffix(cc.URI, "/"), "http"))
 
 	c.vitalsG = provider.Cached(func() (Vitals, error) {
 		var res Vitals
-		uri := fmt.Sprintf("%s/api/1/vitals", c.uri)
-		err := c.GetJSON(uri, &res)
+		err := client.GetJSON(uri, &res)
 		return res, err
-	}, time.Second)
+	}, cc.Cache)
 
 	return c, nil
 }
diff --git a/cmd/check_config.go b/cmd/check_config.go
index ad0cc23e..da265bb2 100644
--- a/cmd/check_config.go
+++ b/cmd/check_config.go
@@ -2,6 +2,7 @@ package cmd
 
 import (
 	_ "embed"
+	"fmt"
 	"os"
 
 	"github.com/spf13/cobra"
@@ -24,6 +25,6 @@ func runConfigCheck(cmd *cobra.Command, args []string) {
 		log.FATAL.Println("config invalid:", err)
 		os.Exit(1)
 	} else {
-		log.INFO.Println("config valid")
+		fmt.Println("config valid")
 	}
 }
diff --git a/cmd/setup.go b/cmd/setup.go
index 02ee0128..14470200 100644
--- a/cmd/setup.go
+++ b/cmd/setup.go
@@ -153,6 +153,13 @@ func (c networkConfig) URI() string {
 	return fmt.Sprintf("%s://%s", c.Schema, c.HostPort())
 }
 
+func nameValid(name string) error {
+	if !nameRE.MatchString(name) {
+		return fmt.Errorf("name must not contain special characters or spaces: %s", name)
+	}
+	return nil
+}
+
 func loadConfigFile(conf *globalConfig) error {
 	err := viper.ReadInConfig()
 
@@ -186,6 +193,10 @@ func configureMeters(static []config.Named, names ...string) error {
 			continue
 		}
 
+		if err := nameValid(cc.Name); err != nil {
+			log.WARN.Printf("create meter %d: %v", i+1, err)
+		}
+
 		instance, err := meter.NewFromConfig(cc.Type, cc.Other)
 		if err != nil {
 			return fmt.Errorf("cannot create meter '%s': %w", cc.Name, err)
@@ -234,6 +245,10 @@ func configureChargers(static []config.Named, names ...string) error {
 			continue
 		}
 
+		if err := nameValid(cc.Name); err != nil {
+			log.WARN.Printf("create charger %d: %v", i+1, err)
+		}
+
 		g.Go(func() error {
 			instance, err := charger.NewFromConfig(cc.Type, cc.Other)
 			if err != nil {
@@ -271,10 +286,6 @@ func configureChargers(static []config.Named, names ...string) error {
 }
 
 func vehicleInstance(cc config.Named) (api.Vehicle, error) {
-	if !nameRE.MatchString(cc.Name) {
-		return nil, fmt.Errorf("vehicle name must not contain special characters or spaces: %s", cc.Name)
-	}
-
 	instance, err := vehicle.NewFromConfig(cc.Type, cc.Other)
 	if err != nil {
 		var ce *util.ConfigError
@@ -284,7 +295,7 @@ func vehicleInstance(cc config.Named) (api.Vehicle, error) {
 
 		// wrap non-config vehicle errors to prevent fatals
 		log.ERROR.Printf("creating vehicle %s failed: %v", cc.Name, err)
-		instance = vehicle.NewWrapper(cc.Name, cc.Other, err)
+		instance = vehicle.NewWrapper(cc.Name, cc.Type, cc.Other, err)
 	}
 
 	// ensure vehicle config has title
@@ -312,6 +323,10 @@ func configureVehicles(static []config.Named, names ...string) error {
 			continue
 		}
 
+		if err := nameValid(cc.Name); err != nil {
+			return fmt.Errorf("cannot create vehicle %d: %w", i+1, err)
+		}
+
 		g.Go(func() error {
 			instance, err := vehicleInstance(cc)
 			if err != nil {
diff --git a/core/loadpoint.go b/core/loadpoint.go
index cb8df4d1..bec45974 100644
--- a/core/loadpoint.go
+++ b/core/loadpoint.go
@@ -237,7 +237,7 @@ func NewLoadpointFromConfig(log *util.Logger, settings *Settings, other map[stri
 
 	// TODO deprecated
 	if lp.MinCurrent_ > 0 {
-		lp.log.WARN.Println("deprecated: minCurrent setting is ignored, please remove")
+		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_)
 		}
@@ -658,6 +658,11 @@ func (lp *Loadpoint) syncCharger() error {
 		return err
 	}
 
+	// some chargers (i.E. Easee in some configurations) disable themself to be able to switch phases
+	if !enabled && lp.enabled && !lp.phaseSwitchCompleted() {
+		return lp.charger.Enable(true) // enable charger
+	}
+
 	if lp.chargerUpdateCompleted() {
 		defer func() {
 			lp.enabled = enabled
@@ -1422,7 +1427,7 @@ func (lp *Loadpoint) publishSocAndRange() {
 				targetSoc = int(math.Trunc(limit))
 				lp.log.DEBUG.Printf("vehicle soc limit: %.0f%%", limit)
 				lp.publish(keys.VehicleTargetSoc, limit)
-			} else {
+			} else if !errors.Is(err, api.ErrNotAvailable) {
 				lp.log.ERROR.Printf("vehicle soc limit: %v", err)
 			}
 		}
diff --git a/core/loadpoint_effective.go b/core/loadpoint_effective.go
index 57a441f6..73ee4468 100644
--- a/core/loadpoint_effective.go
+++ b/core/loadpoint_effective.go
@@ -60,26 +60,29 @@ func (lp *Loadpoint) SocBasedPlanning() bool {
 
 // effectiveMinCurrent returns the effective min current
 func (lp *Loadpoint) effectiveMinCurrent() float64 {
-	minCurrent := lp.GetMinCurrent()
+	lpMin := lp.GetMinCurrent()
+	var vehicleMin, chargerMin float64
 
 	if v := lp.GetVehicle(); v != nil {
 		if res, ok := v.OnIdentified().GetMinCurrent(); ok {
-			minCurrent = max(minCurrent, res)
+			vehicleMin = res
 		}
 	}
 
 	if c, ok := lp.charger.(api.CurrentLimiter); ok {
 		if res, _, err := c.GetMinMaxCurrent(); err == nil {
-			if res > 0 && res < minCurrent {
-				minCurrent = res
-			} else {
-				minCurrent = max(minCurrent, res)
-			}
-			lp.publish(keys.EffectiveMinCurrent, minCurrent)
+			chargerMin = res
 		}
 	}
 
-	return minCurrent
+	switch {
+	case max(vehicleMin, chargerMin) == 0:
+		return lpMin
+	case chargerMin > 0:
+		return max(vehicleMin, chargerMin)
+	default:
+		return max(vehicleMin, lpMin)
+	}
 }
 
 // effectiveMaxCurrent returns the effective max current
@@ -87,15 +90,14 @@ func (lp *Loadpoint) effectiveMaxCurrent() float64 {
 	maxCurrent := lp.GetMaxCurrent()
 
 	if v := lp.GetVehicle(); v != nil {
-		if res, ok := v.OnIdentified().GetMaxCurrent(); ok {
+		if res, ok := v.OnIdentified().GetMaxCurrent(); ok && res > 0 {
 			maxCurrent = min(maxCurrent, res)
 		}
 	}
 
 	if c, ok := lp.charger.(api.CurrentLimiter); ok {
-		if _, res, err := c.GetMinMaxCurrent(); err == nil {
+		if _, res, err := c.GetMinMaxCurrent(); err == nil && res > 0 {
 			maxCurrent = min(maxCurrent, res)
-			lp.publish(keys.EffectiveMaxCurrent, maxCurrent)
 		}
 	}
 
diff --git a/i18n/de.toml b/i18n/de.toml
index ae468137..5383391b 100644
--- a/i18n/de.toml
+++ b/i18n/de.toml
@@ -332,6 +332,7 @@ detectionActive = "Fahrzeugerkennung läuft …"
 fallbackName = "Fahrzeug"
 moreActions = "Weitere Aktionen"
 none = "Kein Fahrzeug"
+notReachable = "Fahrzeug war nicht erreichbar. Versuche evcc neu zu starten."
 targetSoc = "Ladelimit"
 temp = "Temperatur"
 tempLimit = "Zieltemp."
diff --git a/i18n/en.toml b/i18n/en.toml
index 64126f8c..e6719116 100644
--- a/i18n/en.toml
+++ b/i18n/en.toml
@@ -331,6 +331,7 @@ detectionActive = "Detecting vehicle…"
 fallbackName = "Vehicle"
 moreActions = "More actions"
 none = "No vehicle"
+notReachable = "Vehicle was not reachable. Try restarting evcc."
 targetSoc = "Limit"
 temp = "Temp."
 tempLimit = "Temp. limit"
diff --git a/packaging/scripts/postremove.sh b/packaging/scripts/postremove.sh
index 6ef3b8ce..57e207b9 100644
--- a/packaging/scripts/postremove.sh
+++ b/packaging/scripts/postremove.sh
@@ -18,51 +18,38 @@ if [ "$1" = "purge" ]; then
 	fi
 fi
 
-
-
-
-# if interactive: Call /usr/bin/evcc checkconfig and check the return code (newer version)
+# if interactive: call `/usr/bin/evcc checkconfig` and check the return code (newer version)
 # if return code is 0, do nothing
 # else: Ask user if he wants to keep the old version (working) or the new version (not working) 
 # Remember the choice with /tmp/.evccrollback and fail new-postrm failed-upgrade old-version new-version to initiate dpkg's rollback
-if [ "$1" = "upgrade" ]; then
-	INTERACTIVE=0
-	# is shell script interactive?
-	if [ -t 0 ]; then
-	  INTERACTIVE=1
-	else
-	  INTERACTIVE=0
+if [ "$1" = "upgrade" ] && [ -t 0 ]; then
+	if ! /usr/bin/evcc checkconfig > /dev/null; then
+		echo "--------------------------------------------------------------------------------"
+		echo "ERROR: your configuration is not compatible with the new version:"
+		/usr/bin/evcc checkconfig --log error || true
+		echo "Please consult the release notes: https://github.com/evcc-io/evcc/releases"
+		echo "--------------------------------------------------------------------------------"
+
+		while true; do
+			echo "Do you want to keep your old (working) version? [Y/n]: "
+			read choice
+			case "$choice" in
+				n*|N*|"")
+					echo "We will keep the new version. Your configuration stays untouched!"
+					break
+					;;
+				y*|Y*)
+					echo "The old version will be restored. Your configuration stays untouched! Following errors are intended:"
+					touch /tmp/.evccrollback
+					exit 1
+					break
+					;;
+				*)
+					;;
+			esac
+		done
 	fi
-
-	if [ $INTERACTIVE -eq 1 ]; then
-      if ! /usr/bin/evcc checkconfig > /dev/null; then
-			echo "--------------------------------------------------------------------------------"
-			echo "ERROR: your evcc configuration is not compatible with the new version. Please consider reading the release notes: https://github.com/evcc-io/evcc/releases"
-			echo "checkconfig Output:" 
-			/usr/bin/evcc checkconfig || true
-			echo "--------------------------------------------------------------------------------"
-	
-			while true; do
-				echo "Do you want to keep your old (working) evcc version? [Y/n]: "
-				read choice
-				case "$choice" in
-					n*|N*|"")
-						echo "We will keep the new version. Your evcc configuration stays untouched!"
-						break
-						;;
-					y*|Y*)
-						echo "The old version will be restored. Your evcc configuration stays untouched! Following errors are intended:"
-						touch /tmp/.evccrollback
-						exit 1
-						break
-						;;
-					*)
-						;;
-				esac
-			done
-		fi
-	fi 
-fi
+fi 
 
 # if upgrade goal fails, new-postrm failed-upgrade old-version new-version is called. It should fail to initiate rollback
 if [ "$1" = "failed-upgrade" ]; then
diff --git a/tariff/groupe-e.go b/tariff/groupe-e.go
index 00a3a486..5df5b128 100644
--- a/tariff/groupe-e.go
+++ b/tariff/groupe-e.go
@@ -27,18 +27,9 @@ func init() {
 }
 
 func NewGroupeEFromConfig(other map[string]interface{}) (api.Tariff, error) {
-	var cc struct {
-		embed `mapstructure:",squash"`
-	}
-
-	if err := util.DecodeOther(other, &cc); err != nil {
-		return nil, err
-	}
-
 	t := &GroupeE{
-		embed: &cc.embed,
-		log:   util.NewLogger("groupe-e"),
-		data:  util.NewMonitor[api.Rates](2 * time.Hour),
+		log:  util.NewLogger("groupe-e"),
+		data: util.NewMonitor[api.Rates](2 * time.Hour),
 	}
 
 	done := make(chan error)
@@ -82,7 +73,7 @@ func (t *GroupeE) run(done chan error) {
 			ar := api.Rate{
 				Start: r.StartTimestamp.Local(),
 				End:   r.EndTimestamp.Local(),
-				Price: t.totalPrice(r.VarioPlus / 1e2), // Rp/kWh
+				Price: r.VarioPlus / 1e2, // Rp/kWh
 			}
 			data = append(data, ar)
 		}
diff --git "a/templates/definition/charger/weidm\303\274ller.yaml" "b/templates/definition/charger/weidm\303\274ller.yaml"
new file mode 100644
index 00000000..e2e9d84c
--- /dev/null
+++ "b/templates/definition/charger/weidm\303\274ller.yaml"
@@ -0,0 +1,13 @@
+template: weidmüller
+products:
+  - brand: Weidmüller
+    description:
+      generic: AC Smart
+capabilities: ["1p3p", "rfid"]
+requirements:
+  evcc: ["sponsorship"]
+params:
+  - name: host
+render: |
+  type: weidmüller
+  uri: {{ .host }}:502
diff --git a/templates/definition/meter/apsystems-ez1.yaml b/templates/definition/meter/apsystems-ez1.yaml
new file mode 100644
index 00000000..b35eb262
--- /dev/null
+++ b/templates/definition/meter/apsystems-ez1.yaml
@@ -0,0 +1,21 @@
+template: apsystems-ez1
+products:
+  - brand: APsystems
+    description:
+      generic: EZ1
+params:
+  - name: usage
+    choice: ["pv"]
+  - name: host
+render: |
+  type: custom
+  {{- if eq .usage "pv" }}
+  power:
+    source: http
+    uri: http://{{ .host }}:8050/getOutputData
+    jq: .data.p1+.data.p2
+  energy:
+    source: http
+    uri: http://{{ .host }}:8050/getOutputData
+    jq: .data.te1+.data.te2
+  {{- end }}
diff --git a/templates/definition/meter/goodwe-hybrid.yaml b/templates/definition/meter/goodwe-hybrid.yaml
index 02f37f48..cc4e77d7 100644
--- a/templates/definition/meter/goodwe-hybrid.yaml
+++ b/templates/definition/meter/goodwe-hybrid.yaml
@@ -30,7 +30,7 @@ render: |
       address: 36017 # E-Total-Buy
       type: holding
       decode: float32
-    scale: 0.1
+    scale: 0.001
   {{- end }}
   {{- if eq .usage "pv" }}
   power:
@@ -64,7 +64,7 @@ render: |
     source: modbus
     {{- include "modbus" . | indent 2 }}
     register: # manual non-sunspec register configuration
-      address: 35191 # PV E-Total Total PV Energy
+      address: 35191 # PV Energy-Total
       type: holding
       decode: uint32
     scale: 0.1
@@ -74,21 +74,21 @@ render: |
     source: modbus
     {{- include "modbus" . | indent 2 }}
     register: # manual non-sunspec register configuration
-      address: 35183 # Pbattery1 First group battery power
+      address: 35182 # Battery1 Power
       type: holding
-      decode: int16
+      decode: int32
   soc:
     source: modbus
     {{- include "modbus" . | indent 2 }}
     register: # manual non-sunspec register configuration
-      address: 37007 # SOC First group battery capacity
+      address: 37007 # SOC
       type: holding
       decode: uint16
   energy:
     source: modbus
     {{- include "modbus" . | indent 2 }}
     register: # manual non-sunspec register configuration
-      address: 35209 # E-BatteryDischarge Discharge energy
+      address: 35209 # Energy-Battery Discharge
       type: holding
       decode: uint32
     scale: 0.1
diff --git a/templates/definition/meter/huawei-smartlogger.yaml b/templates/definition/meter/huawei-smartlogger.yaml
index b3854621..bb48fb11 100644
--- a/templates/definition/meter/huawei-smartlogger.yaml
+++ b/templates/definition/meter/huawei-smartlogger.yaml
@@ -68,22 +68,21 @@ render: |
   {{- if eq .usage "pv" }}
   power:
     source: modbus
-    {{- include "modbus" . | indent 2 }}
-    timeout: {{ .timeout }}
-    connectdelay: 1s
+    id: 0
+    uri: {{ .host }}:{{ .port }}
     register:
-      address: 32064 # Input power DC (if no battery in your system - for more precise readings use 32080 # Active generation power AC)
+      address: 40521 # Active power
       type: holding
-      decode: int32nan
+      decode: int32
   energy:
     source: modbus
-    {{- include "modbus" . | indent 2 }}
-    timeout: {{ .timeout }}
+    id: 0
+    uri: {{ .host }}:{{ .port }}
     register:
-      address: 32106 # Accumulated energy yield
+      address: 40560 # E-Total
       type: holding
-      decode: uint32nan
-    scale: 0.01
+      decode: uint32
+    scale: 0.1
   {{- end }}
   {{- if eq .usage "battery" }}
   power:
diff --git a/templates/definition/meter/smartfox-em2.yaml b/templates/definition/meter/smartfox-em2.yaml
index 7062502e..705face0 100644
--- a/templates/definition/meter/smartfox-em2.yaml
+++ b/templates/definition/meter/smartfox-em2.yaml
@@ -2,7 +2,7 @@ template: smartfox-em2
 products:
   - brand: Smartfox
     description:
-      generic: Pro, Pro 2, Pro Light, Pro Light 2, Light, Reg, Reg extended (EM2 firmware)
+      generic: Pro, Pro 2, Pro Light, Pro Light 2, Light (EM2 firmware)
 requirements:
   description:
     de: |
diff --git a/templates/definition/meter/smartfox.yaml b/templates/definition/meter/smartfox.yaml
index b79d8fa2..fdf2c8e9 100644
--- a/templates/definition/meter/smartfox.yaml
+++ b/templates/definition/meter/smartfox.yaml
@@ -2,7 +2,7 @@ template: smartfox
 products:
   - brand: Smartfox
     description:
-      generic: Pro, Pro 2, Pro Light, Pro Light 2, Light, Reg, Reg extended
+      generic: Box, Reg, Reg extended
 requirements:
   description:
     de: |
@@ -75,4 +75,9 @@ render: |
     source: http
     uri: {{ include "uri" . }}
     jq: .power_sf
+  energy:
+    source: http
+    uri: {{ include "uri" . }}
+    jq: .day_energy_sf
+    scale: 0.001
   {{- end }}
diff --git a/util/cloud/client.go b/util/cloud/client.go
index 84a5ec2a..414da155 100644
--- a/util/cloud/client.go
+++ b/util/cloud/client.go
@@ -3,6 +3,7 @@ package cloud
 import (
 	"crypto/tls"
 	_ "embed"
+	"net"
 
 	"github.com/evcc-io/evcc/util"
 	"google.golang.org/grpc"
@@ -10,7 +11,7 @@ import (
 )
 
 var (
-	host = util.Getenv("GRPC_URI", "sponsor.evcc.io:8080")
+	hostport = util.Getenv("GRPC_URI", "sponsor.evcc.io:8080")
 
 	conn *grpc.ClientConn
 )
@@ -20,9 +21,15 @@ func Connection() (*grpc.ClientConn, error) {
 		return conn, nil
 	}
 
-	var err error
-	creds := credentials.NewTLS(new(tls.Config))
-	conn, err = grpc.Dial(host, grpc.WithTransportCredentials(creds))
+	host, _, err := net.SplitHostPort(hostport)
+	if err != nil {
+		return nil, err
+	}
+
+	creds := credentials.NewTLS(&tls.Config{
+		ServerName: host,
+	})
+	conn, err = grpc.Dial(hostport, grpc.WithTransportCredentials(creds))
 
 	return conn, err
 }
diff --git a/util/modbus/functions.go b/util/modbus/functions.go
index a6fc0f5c..d5c0bc87 100644
--- a/util/modbus/functions.go
+++ b/util/modbus/functions.go
@@ -3,6 +3,7 @@ package modbus
 import (
 	"encoding/binary"
 	"encoding/hex"
+	"errors"
 	"fmt"
 	"strconv"
 	"strings"
@@ -12,6 +13,10 @@ import (
 func decodeMask(mask string) (uint64, error) {
 	mask = strings.ToLower(mask)
 
+	if mask == "" {
+		return 0, errors.New("mask is required")
+	}
+
 	if strings.HasPrefix(mask, "0x") {
 		if len(mask) < 3 {
 			return 0, fmt.Errorf("invalid mask: %s", mask)
diff --git a/vehicle/ovms.go b/vehicle/ovms.go
index c4def163..9a292e45 100644
--- a/vehicle/ovms.go
+++ b/vehicle/ovms.go
@@ -6,38 +6,16 @@ import (
 	"net/http"
 	"net/http/cookiejar"
 	"net/url"
-	"strconv"
 	"time"
 
 	"github.com/evcc-io/evcc/api"
 	"github.com/evcc-io/evcc/provider"
 	"github.com/evcc-io/evcc/util"
 	"github.com/evcc-io/evcc/util/request"
+	"github.com/evcc-io/evcc/vehicle/ovms"
 	"golang.org/x/net/publicsuffix"
 )
 
-type ovmsStatusResponse struct {
-	Odometer float64 `json:"odometer,string"`
-}
-
-type ovmsChargeResponse struct {
-	ChargeEtrFull    int64   `json:"charge_etr_full,string"`
-	ChargeState      string  `json:"chargestate"`
-	ChargePortOpen   int     `json:"cp_dooropen"`
-	EstimatedRange   string  `json:"estimatedrange"`
-	MessageAgeServer int     `json:"m_msgage_s"`
-	Soc              float64 `json:"soc,string"`
-}
-
-type ovmsLocationResponse struct {
-	Latitude  float64 `json:"latitude,string"`
-	Longitude float64 `json:"longitude,string"`
-}
-
-type ovmsConnectResponse struct {
-	NetConnected int `json:"v_net_connected"`
-}
-
 // OVMS is an api.Vehicle implementation for dexters-web server requests
 type Ovms struct {
 	*embed
@@ -46,9 +24,9 @@ type Ovms struct {
 	vehicleId, server string
 	cache             time.Duration
 	isOnline          bool
-	chargeG           func() (ovmsChargeResponse, error)
-	statusG           func() (ovmsStatusResponse, error)
-	locationG         func() (ovmsLocationResponse, error)
+	chargeG           func() (ovms.ChargeResponse, error)
+	statusG           func() (ovms.StatusResponse, error)
+	locationG         func() (ovms.LocationResponse, error)
 }
 
 func init() {
@@ -107,32 +85,32 @@ func (v *Ovms) uri(path string) string {
 	return fmt.Sprintf("https://%s/api/%s/%s", net.JoinHostPort(v.server, "6869"), path, v.vehicleId)
 }
 
-func (v *Ovms) connectRequest() (ovmsConnectResponse, error) {
-	var res ovmsConnectResponse
+func (v *Ovms) connectRequest() (ovms.ConnectResponse, error) {
+	var res ovms.ConnectResponse
 	err := v.GetJSON(v.uri("vehicle"), &res)
 	return res, err
 }
 
-func (v *Ovms) chargeRequest() (ovmsChargeResponse, error) {
-	var res ovmsChargeResponse
+func (v *Ovms) chargeRequest() (ovms.ChargeResponse, error) {
+	var res ovms.ChargeResponse
 	err := v.GetJSON(v.uri("charge"), &res)
 	return res, err
 }
 
-func (v *Ovms) statusRequest() (ovmsStatusResponse, error) {
-	var res ovmsStatusResponse
+func (v *Ovms) statusRequest() (ovms.StatusResponse, error) {
+	var res ovms.StatusResponse
 	err := v.GetJSON(v.uri("status"), &res)
 	return res, err
 }
 
-func (v *Ovms) locationRequest() (ovmsLocationResponse, error) {
-	var res ovmsLocationResponse
+func (v *Ovms) locationRequest() (ovms.LocationResponse, error) {
+	var res ovms.LocationResponse
 	err := v.GetJSON(v.uri("location"), &res)
 	return res, err
 }
 
 func (v *Ovms) authFlow() error {
-	var resp ovmsConnectResponse
+	var resp ovms.ConnectResponse
 	err := v.loginToServer()
 	if err == nil {
 		resp, err = v.connectRequest()
@@ -144,8 +122,8 @@ func (v *Ovms) authFlow() error {
 }
 
 // batteryAPI provides battery-status api response
-func (v *Ovms) batteryAPI() (ovmsChargeResponse, error) {
-	var resp ovmsChargeResponse
+func (v *Ovms) batteryAPI() (ovms.ChargeResponse, error) {
+	var resp ovms.ChargeResponse
 
 	resp, err := v.chargeRequest()
 	if err != nil {
@@ -164,8 +142,8 @@ func (v *Ovms) batteryAPI() (ovmsChargeResponse, error) {
 }
 
 // statusAPI provides vehicle status api response
-func (v *Ovms) statusAPI() (ovmsStatusResponse, error) {
-	var resp ovmsStatusResponse
+func (v *Ovms) statusAPI() (ovms.StatusResponse, error) {
+	var resp ovms.StatusResponse
 
 	resp, err := v.statusRequest()
 	if err != nil {
@@ -179,8 +157,8 @@ func (v *Ovms) statusAPI() (ovmsStatusResponse, error) {
 }
 
 // location API provides vehicle position api response
-func (v *Ovms) locationAPI() (ovmsLocationResponse, error) {
-	var resp ovmsLocationResponse
+func (v *Ovms) locationAPI() (ovms.LocationResponse, error) {
+	var resp ovms.LocationResponse
 
 	resp, err := v.locationRequest()
 	if err != nil {
@@ -220,15 +198,15 @@ func (v *Ovms) Status() (api.ChargeStatus, error) {
 
 var _ api.VehicleRange = (*Ovms)(nil)
 
+const kmPerMile = 1.609344
+
 // Range implements the api.VehicleRange interface
 func (v *Ovms) Range() (int64, error) {
 	res, err := v.chargeG()
-
-	if err == nil {
-		return strconv.ParseInt(res.EstimatedRange, 0, 64)
+	if res.Units == ovms.UnitMiles {
+		res.EstimatedRange = int64(float64(res.EstimatedRange) * kmPerMile)
 	}
-
-	return 0, err
+	return res.EstimatedRange, err
 }
 
 var _ api.VehicleOdometer = (*Ovms)(nil)
@@ -236,6 +214,9 @@ var _ api.VehicleOdometer = (*Ovms)(nil)
 // Odometer implements the api.VehicleOdometer interface
 func (v *Ovms) Odometer() (float64, error) {
 	res, err := v.statusG()
+	if res.Units == ovms.UnitMiles {
+		res.Odometer *= kmPerMile
+	}
 	return res.Odometer / 10, err
 }
 
diff --git a/vehicle/ovms/types.go b/vehicle/ovms/types.go
new file mode 100644
index 00000000..3de29e46
--- /dev/null
+++ b/vehicle/ovms/types.go
@@ -0,0 +1,27 @@
+package ovms
+
+const UnitMiles = "M"
+
+type StatusResponse struct {
+	Units    string  `json:"units"`
+	Odometer float64 `json:"odometer,string"`
+}
+
+type ChargeResponse struct {
+	Units            string  `json:"units"`
+	ChargeEtrFull    int64   `json:"charge_etr_full,string"`
+	ChargeState      string  `json:"chargestate"`
+	ChargePortOpen   int     `json:"cp_dooropen"`
+	EstimatedRange   int64   `json:"estimatedrange,string"`
+	MessageAgeServer int     `json:"m_msgage_s"`
+	Soc              float64 `json:"soc,string"`
+}
+
+type LocationResponse struct {
+	Latitude  float64 `json:"latitude,string"`
+	Longitude float64 `json:"longitude,string"`
+}
+
+type ConnectResponse struct {
+	NetConnected int `json:"v_net_connected"`
+}
diff --git a/vehicle/vw/id/provider.go b/vehicle/vw/id/provider.go
index f5660ea9..ec62d80e 100644
--- a/vehicle/vw/id/provider.go
+++ b/vehicle/vw/id/provider.go
@@ -149,15 +149,11 @@ var _ api.SocLimiter = (*Provider)(nil)
 // TargetSoc implements the api.SocLimiter interface
 func (v *Provider) TargetSoc() (float64, error) {
 	res, err := v.statusG()
-	if err == nil && res.Charging == nil {
-		err = errors.New("missing charging status")
-	}
-
-	if err == nil {
-		return float64(res.Charging.ChargingSettings.Value.TargetSOCPct), nil
+	if err != nil || res.Charging == nil || res.Charging.ChargingSettings.Value.TargetSOCPct == nil {
+		return 0, api.ErrNotAvailable
 	}
 
-	return 0, err
+	return float64(*res.Charging.ChargingSettings.Value.TargetSOCPct), nil
 }
 
 var _ api.VehicleChargeController = (*Provider)(nil)
diff --git a/vehicle/vw/id/types.go b/vehicle/vw/id/types.go
index a303eb83..00dea23f 100644
--- a/vehicle/vw/id/types.go
+++ b/vehicle/vw/id/types.go
@@ -108,7 +108,7 @@ type Status struct {
 				MaxChargeCurrentAC          string    `json:"maxChargeCurrentAC"` // reduced, maximum
 				AutoUnlockPlugWhenCharged   string    `json:"autoUnlockPlugWhenCharged"`
 				AutoUnlockPlugWhenChargedAC string    `json:"autoUnlockPlugWhenChargedAC"`
-				TargetSOCPct                int       `json:"targetSOC_pct"`
+				TargetSOCPct                *int      `json:"targetSOC_pct"`
 			} `json:"value"`
 		} `json:"chargingSettings"`
 		PlugStatus struct {
diff --git a/vehicle/wrapper.go b/vehicle/wrapper.go
index d92eb0a0..6727185b 100644
--- a/vehicle/wrapper.go
+++ b/vehicle/wrapper.go
@@ -11,11 +11,13 @@ import (
 // Wrapper wraps an api.Vehicle to capture initialization errors
 type Wrapper struct {
 	embed
-	err error
+	typ    string
+	config map[string]interface{}
+	err    error
 }
 
 // NewWrapper creates an offline Vehicle wrapper
-func NewWrapper(name string, other map[string]interface{}, err error) api.Vehicle {
+func NewWrapper(name string, typ string, other map[string]interface{}, err error) api.Vehicle {
 	var cc struct {
 		embed `mapstructure:",squash"`
 		Other map[string]interface{} `mapstructure:",remain"`
@@ -30,11 +32,13 @@ func NewWrapper(name string, other map[string]interface{}, err error) api.Vehicl
 	}
 
 	v := &Wrapper{
-		embed: cc.embed,
-		err:   fmt.Errorf("vehicle not available: %w", err),
+		embed:  cc.embed,
+		typ:    typ,
+		config: cc.Other,
+		err:    fmt.Errorf("vehicle not available: %w", err),
 	}
 
-	v.Features_ = append(v.Features_, api.Offline)
+	v.Features_ = append(v.Features_, api.Offline, api.Retryable)
 	v.SetTitle(cc.Title_)
 
 	return v
@@ -45,11 +49,16 @@ func (v *Wrapper) Error() string {
 	return v.err.Error()
 }
 
+// Error returns the initialization error
+func (v *Wrapper) Config() (string, map[string]interface{}) {
+	return v.typ, v.config
+}
+
 var _ api.Vehicle = (*Wrapper)(nil)
 
 // SetTitle implements the api.TitleSetter interface
 func (v *Wrapper) SetTitle(title string) {
-	v.Title_ = fmt.Sprintf("%s (unavailable)", title)
+	v.Title_ = title
 }
 
 var _ api.Battery = (*Wrapper)(nil)


Test patch:
diff --git a/core/loadpoint_effective_test.go b/core/loadpoint_effective_test.go
index 52e2957a..f2165478 100644
--- a/core/loadpoint_effective_test.go
+++ b/core/loadpoint_effective_test.go
@@ -21,12 +21,16 @@ func TestEffectiveMinMaxCurrent(t *testing.T) {
 		effectiveMin, effectiveMax float64
 	}{
 		{0, 0, 0, 0, 6, 16},
-		{1, 10, 0, 0, 1, 10},     // charger lower
-		{10, 20, 0, 0, 10, 16},   // charger higher - max ignored
-		{0, 0, 1, 10, 6, 10},     // vehicle lower - min ignored
-		{0, 0, 10, 20, 10, 16},   // vehicle higher - max ignored
-		{1, 10, 2, 12, 1, 10},    // charger + vehicle lower
-		{10, 20, 12, 22, 10, 16}, // charger + vehicle higher
+		{2, 0, 0, 0, 2, 16},   // charger min lower, max empty - charger wins
+		{7, 0, 0, 0, 7, 16},   // charger min higher, max empty (no practical use)
+		{0, 10, 0, 0, 6, 10},  // charger max lower, min empty - loadpoint wins
+		{0, 20, 0, 0, 6, 16},  // charger max higher, min empty - loadpoint wins
+		{0, 0, 5, 0, 6, 16},   // vehicle min lower, max empty - loadpoint wins
+		{0, 0, 8, 0, 8, 16},   // vehicle min higher, max empty - vehicle wins
+		{0, 0, 0, 10, 6, 10},  // vehicle max lower, min empty - vehicle wins
+		{0, 0, 0, 20, 6, 16},  // vehicle max higher, min empty - loadpoint wins
+		{2, 0, 5, 0, 5, 16},   // charger + vehicle min lower, max empty - vehicle wins
+		{0, 20, 0, 32, 6, 16}, // charger + vehicle max higher, min empty - loadpoint wins
 	}
 
 	for _, tc := range tc {
@@ -60,7 +64,7 @@ func TestEffectiveMinMaxCurrent(t *testing.T) {
 			lp.vehicle = vehicle
 		}
 
-		assert.Equal(t, tc.effectiveMin, lp.effectiveMinCurrent())
-		assert.Equal(t, tc.effectiveMax, lp.effectiveMaxCurrent())
+		assert.Equal(t, tc.effectiveMin, lp.effectiveMinCurrent(), "min")
+		assert.Equal(t, tc.effectiveMax, lp.effectiveMaxCurrent(), "max")
 	}
 }
diff --git a/tests/vehicle-error.evcc.yaml b/tests/vehicle-error.evcc.yaml
new file mode 100755
index 00000000..bcce474b
--- /dev/null
+++ b/tests/vehicle-error.evcc.yaml
@@ -0,0 +1,54 @@
+interval: 0.1s
+
+site:
+  title: Vehicle Error
+  meters:
+    grid: grid
+
+meters:
+  - name: grid
+    type: custom
+    power:
+      source: js
+      script: |
+        1000
+  - name: charger_meter
+    type: custom
+    power:
+      source: js
+      script: |
+        500
+
+loadpoints:
+  - title: Carport
+    charger: charger
+    meter: charger_meter
+    vehicle: broken_tesla
+    mode: now
+
+chargers:
+  - name: charger
+    type: custom
+    enable:
+      source: js
+      script:
+    enabled:
+      source: js
+      script: |
+        true
+    status:
+      source: js
+      script: |
+        "C"
+    maxcurrent:
+      source: js
+      script: |
+        16
+
+vehicles:
+  - name: broken_tesla
+    type: template
+    template: tesla # not optimal, since real communication with tesla server is happening
+    title: Broken Tesla
+    accessToken: A
+    refreshToken: B
diff --git a/tests/vehicle-error.spec.js b/tests/vehicle-error.spec.js
new file mode 100644
index 00000000..e329d319
--- /dev/null
+++ b/tests/vehicle-error.spec.js
@@ -0,0 +1,29 @@
+const { test, expect } = require("@playwright/test");
+const { start, stop } = require("./evcc");
+
+test.beforeAll(async () => {
+  await start("vehicle-error.evcc.yaml");
+});
+test.afterAll(async () => {
+  await stop();
+});
+
+test.beforeEach(async ({ page }) => {
+  await page.goto("/");
+});
+
+test.describe("vehicle startup error", async () => {
+  test("broken vehicle: normal title and 'not reachable' icon", async ({ page }) => {
+    await expect(page.getByTestId("vehicle-name")).toHaveText("Broken Tesla");
+    await expect(page.getByTestId("vehicle-not-reachable-icon")).toBeVisible();
+  });
+
+  test("guest vehicle: normal title and no icon", async ({ page }) => {
+    // switch to offline vehicle
+    await page.getByRole("button", { name: "Broken Tesla" }).click();
+    await page.getByRole("button", { name: "Guest vehicle" }).click();
+
+    await expect(page.getByTestId("vehicle-name")).toHaveText("Guest vehicle");
+    await expect(page.getByTestId("vehicle-not-reachable-icon")).not.toBeVisible();
+  });
+});