Repository: evcc-io/evcc
Pull request #17982 (2024-12-31 13:41:32)

Tesla: require personal developer account
Fix https://github.com/evcc-io/evcc/issues/17981

TODO

- [x] validate token refresh @naltatis 
- [x] migrate commands
- [x] test commands @naltatis 
- [x] remove build secret
- [x] update template docs
- [x] proper communication (release notes / blog post / how to)

See https://github.com/evcc-io/evcc/discussions/17501

**Breaking Change**

See new configuration instructions:

> Tesla offers an official, but paid vehicle API. For private use, you can create a [Tesla Developer Account](https://developer.tesla.com/) and receive a monthly API credit of $10. This is usually sufficient for the common evcc use cases.
>  
> The [myteslamate.com](https://www.myteslamate.com/tesla-api-application-registration/) guide explains the process and generates a free Access and Refresh Token. With this token pair and your Client ID created in the Tesla Developer Account, evcc can directly communicate with the Tesla API. You can see your used credit at Tesla.
> 
> To use a Tesla Wall Connector, you need a public Command Proxy Server. [myteslamate.com](https://app.myteslamate.com/) provides this service for 12€/year. Configure the Command permissions at myteslamate.com and enter the Proxy Token here. Start, stopp and current commands are sent to Tesla via this proxy.



Patch:
diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml
index f1985d8f..e60cdeaf 100644
--- a/.github/workflows/nightly.yml
+++ b/.github/workflows/nightly.yml
@@ -68,8 +68,6 @@ jobs:
           context: .
           platforms: linux/amd64,linux/arm64,linux/arm/v6
           push: true
-          build-args: |
-            TESLA_CLIENT_ID=${{ secrets.TESLA_CLIENT_ID }}
           tags: |
             evcc/evcc:nightly
 
@@ -106,7 +104,6 @@ jobs:
           args: --snapshot -f .goreleaser-nightly.yml --clean
         env:
           GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
-          TESLA_CLIENT_ID: ${{ secrets.TESLA_CLIENT_ID }}
 
       - uses: actions/setup-python@v5
         with:
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index bb7662f8..06318113 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -48,7 +48,6 @@ jobs:
           push: true
           build-args: |
             RELEASE=1
-            TESLA_CLIENT_ID=${{ secrets.TESLA_CLIENT_ID }}
           tags: ${{ steps.meta.outputs.tags }}
 
   apt:
@@ -100,7 +99,6 @@ jobs:
         env:
           # use GH_TOKEN for access to evcc-io/homebrew-tap
           GITHUB_TOKEN: ${{ secrets.GH_TOKEN }}
-          TESLA_CLIENT_ID: ${{ secrets.TESLA_CLIENT_ID }}
 
       - uses: actions/setup-python@v5
         with:
diff --git a/.goreleaser-nightly.yml b/.goreleaser-nightly.yml
index bba2360c..7438c6fe 100644
--- a/.goreleaser-nightly.yml
+++ b/.goreleaser-nightly.yml
@@ -11,7 +11,7 @@ builds:
       - -trimpath
       - -tags=release
     ldflags:
-      - -X github.com/evcc-io/evcc/server.Version={{ .Tag }} -X github.com/evcc-io/evcc/server.Commit={{ .ShortCommit }} -X github.com/evcc-io/evcc/vehicle/tesla.TESLA_CLIENT_ID={{ .Env.TESLA_CLIENT_ID }} -s -w
+      - -X github.com/evcc-io/evcc/server.Version={{ .Tag }} -X github.com/evcc-io/evcc/server.Commit={{ .ShortCommit }} -s -w
     env:
       - CGO_ENABLED=0
     goos:
@@ -36,11 +36,11 @@ builds:
       - goos: darwin
         goarch: arm64
         ldflags:
-          - -X github.com/evcc-io/evcc/server.Version={{ .Tag }} -X github.com/evcc-io/evcc/server.Commit={{ .ShortCommit }} -X github.com/evcc-io/evcc/vehicle/tesla.TESLA_CLIENT_ID={{ .Env.TESLA_CLIENT_ID }} -s -w -B gobuildid
+          - -X github.com/evcc-io/evcc/server.Version={{ .Tag }} -X github.com/evcc-io/evcc/server.Commit={{ .ShortCommit }} -s -w -B gobuildid
       - goos: darwin
         goarch: amd64
         ldflags:
-          - -X github.com/evcc-io/evcc/server.Version={{ .Tag }} -X github.com/evcc-io/evcc/server.Commit={{ .ShortCommit }} -X github.com/evcc-io/evcc/vehicle/tesla.TESLA_CLIENT_ID={{ .Env.TESLA_CLIENT_ID }} -s -w -B gobuildid
+          - -X github.com/evcc-io/evcc/server.Version={{ .Tag }} -X github.com/evcc-io/evcc/server.Commit={{ .ShortCommit }} -s -w -B gobuildid
 
 archives:
   - builds:
diff --git a/.goreleaser.yml b/.goreleaser.yml
index 344981ca..a8408878 100644
--- a/.goreleaser.yml
+++ b/.goreleaser.yml
@@ -14,7 +14,7 @@ builds:
       - -trimpath
       - -tags=release
     ldflags:
-      - -X github.com/evcc-io/evcc/server.Version={{ .Version }} -X github.com/evcc-io/evcc/vehicle/tesla.TESLA_CLIENT_ID={{ .Env.TESLA_CLIENT_ID }} -s -w
+      - -X github.com/evcc-io/evcc/server.Version={{ .Version }} -s -w
     env:
       - CGO_ENABLED=0
     goos:
@@ -41,11 +41,11 @@ builds:
       - goos: darwin
         goarch: arm64
         ldflags:
-          - -X github.com/evcc-io/evcc/server.Version={{ .Version }} -X github.com/evcc-io/evcc/vehicle/tesla.TESLA_CLIENT_ID={{ .Env.TESLA_CLIENT_ID }} -s -w -B gobuildid
+          - -X github.com/evcc-io/evcc/server.Version={{ .Version }} -s -w -B gobuildid
       - goos: darwin
         goarch: amd64
         ldflags:
-          - -X github.com/evcc-io/evcc/server.Version={{ .Version }} -X github.com/evcc-io/evcc/vehicle/tesla.TESLA_CLIENT_ID={{ .Env.TESLA_CLIENT_ID }} -s -w -B gobuildid
+          - -X github.com/evcc-io/evcc/server.Version={{ .Version }} -s -w -B gobuildid
 
 env:
   - CGO_ENABLED=0
diff --git a/api/error.go b/api/error.go
index ce4d78ac..72b07b97 100644
--- a/api/error.go
+++ b/api/error.go
@@ -12,7 +12,10 @@ var ErrMustRetry = errors.New("must retry")
 var ErrSponsorRequired = errors.New("sponsorship required, see https://github.com/evcc-io/evcc#sponsorship")
 
 // ErrMissingCredentials indicates that user/password are missing
-var ErrMissingCredentials = errors.New("missing credentials")
+var ErrMissingCredentials = errors.New("missing user/password credentials")
+
+// ErrMissingToken indicates that access/refresh tokens are missing
+var ErrMissingToken = errors.New("missing token credentials")
 
 // ErrOutdated indicates that result is outdated
 var ErrOutdated = errors.New("outdated")
diff --git a/assets/js/components/Config/defaultYaml/tariffs.yaml b/assets/js/components/Config/defaultYaml/tariffs.yaml
index f7e9e913..49b44c59 100644
--- a/assets/js/components/Config/defaultYaml/tariffs.yaml
+++ b/assets/js/components/Config/defaultYaml/tariffs.yaml
@@ -9,5 +9,6 @@
 #  price: 0.08 # EUR/kWh
 
 #co2: # carbon intensity forecast data
-#  type: grünstromindex
+#  type: template
+#  template: grünstromindex
 #  zip: <zip>
diff --git a/assets/js/components/Energyflow/Energyflow.vue b/assets/js/components/Energyflow/Energyflow.vue
index 30f86aa6..76013265 100644
--- a/assets/js/components/Energyflow/Energyflow.vue
+++ b/assets/js/components/Energyflow/Energyflow.vue
@@ -103,7 +103,7 @@
 							data-testid="energyflow-entry-batterydischarge"
 							@details-clicked="openBatterySettingsModal"
 						>
-							<template v-if="batteryGridChargeActive" #subline>
+							<template v-if="batteryGridChargeLimitSet" #subline>
 								<div class="d-none d-md-block">&nbsp;</div>
 							</template>
 						</EnergyflowEntry>
@@ -177,13 +177,25 @@
 							detailsClickable
 							@details-clicked="openBatterySettingsModal"
 						>
-							<template v-if="batteryGridChargeActive" #subline>
+							<template v-if="batteryGridChargeLimitSet" #subline>
 								<button
 									type="button"
-									class="btn-reset d-flex justify-content-between"
+									class="btn-reset d-flex justify-content-between text-start pe-4"
 									@click.stop="openBatterySettingsModal"
 								>
-									{{ batteryGridChargeText }} (≤ {{ batteryGridChargeLimitFmt }})
+									<span v-if="batteryGridChargeActive">
+										{{ $t("main.energyflow.batteryGridChargeActive") }}
+										<span class="text-nowrap"
+											>(≤ <u>{{ batteryGridChargeLimitFmt }}</u
+											>)</span
+										>
+									</span>
+									<span v-else>
+										{{ $t("main.energyflow.batteryGridChargeLimit") }}
+										<span class="text-nowrap"
+											>≤ <u>{{ batteryGridChargeLimitFmt }}</u></span
+										>
+									</span>
 								</button>
 							</template>
 						</EnergyflowEntry>
@@ -341,19 +353,17 @@ export default {
 		pvPossible() {
 			return this.pvConfigured || this.gridConfigured;
 		},
-		batteryGridChargeText() {
-			return this.$t(
-				`main.energyflow.${this.co2Available ? "clean" : "cheap"}BatteryGridCharge`
-			);
-		},
 		batteryGridChargeNow() {
 			if (this.co2Available) {
 				return this.fmtCo2Short(this.tariffCo2);
 			}
 			return this.fmtPricePerKWh(this.tariffGrid, this.currency, true);
 		},
+		batteryGridChargeLimitSet() {
+			return this.batteryGridChargeLimit !== null;
+		},
 		batteryGridChargeLimitFmt() {
-			if (this.batteryGridChargeLimit === null) {
+			if (!this.batteryGridChargeLimitSet) {
 				return;
 			}
 			if (this.co2Available) {
diff --git a/assets/js/components/Sessions/AvgCostGroupedChart.vue b/assets/js/components/Sessions/AvgCostGroupedChart.vue
index 75feaf48..3af675bf 100644
--- a/assets/js/components/Sessions/AvgCostGroupedChart.vue
+++ b/assets/js/components/Sessions/AvgCostGroupedChart.vue
@@ -92,8 +92,7 @@ export default {
 					...commonOptions.plugins,
 					tooltip: {
 						...commonOptions.plugins.tooltip,
-						intersect: false,
-						mode: "index",
+						axis: "r",
 						position: "topBottomCenter",
 						callbacks: {
 							title: () => null,
diff --git a/assets/js/components/Sessions/CostGroupedChart.vue b/assets/js/components/Sessions/CostGroupedChart.vue
index 3ace87e2..fc010d1a 100644
--- a/assets/js/components/Sessions/CostGroupedChart.vue
+++ b/assets/js/components/Sessions/CostGroupedChart.vue
@@ -86,9 +86,8 @@ export default {
 					...commonOptions.plugins,
 					tooltip: {
 						...commonOptions.plugins.tooltip,
-						mode: "index",
+						axis: "r",
 						position: "center",
-						intersect: false,
 						callbacks: {
 							label: (tooltipItem) => this.formatValue(tooltipItem.raw || 0),
 							labelColor: tooltipLabelColor(false),
diff --git a/assets/js/components/Sessions/CostHistoryChart.vue b/assets/js/components/Sessions/CostHistoryChart.vue
index 59dbe09f..d1df88d0 100644
--- a/assets/js/components/Sessions/CostHistoryChart.vue
+++ b/assets/js/components/Sessions/CostHistoryChart.vue
@@ -223,8 +223,7 @@ export default {
 					...commonOptions.plugins,
 					tooltip: {
 						...commonOptions.plugins.tooltip,
-						mode: "index",
-						intersect: false,
+						axis: "x",
 						positioner: (context) => {
 							const { chart, tooltipPosition } = context;
 							const { tooltip } = chart;
diff --git a/assets/js/components/Sessions/EnergyGroupedChart.vue b/assets/js/components/Sessions/EnergyGroupedChart.vue
index 0f49d25d..560e788e 100644
--- a/assets/js/components/Sessions/EnergyGroupedChart.vue
+++ b/assets/js/components/Sessions/EnergyGroupedChart.vue
@@ -102,9 +102,8 @@ export default {
 					...commonOptions.plugins,
 					tooltip: {
 						...commonOptions.plugins.tooltip,
-						mode: "index",
+						axis: "r",
 						position: "center",
-						intersect: false,
 						callbacks: {
 							label: (tooltipItem) => this.formatValue(tooltipItem.raw || 0),
 							labelColor: tooltipLabelColor(false),
diff --git a/assets/js/components/Sessions/EnergyHistoryChart.vue b/assets/js/components/Sessions/EnergyHistoryChart.vue
index 20814479..73c5a256 100644
--- a/assets/js/components/Sessions/EnergyHistoryChart.vue
+++ b/assets/js/components/Sessions/EnergyHistoryChart.vue
@@ -159,8 +159,7 @@ export default {
 					...commonOptions.plugins,
 					tooltip: {
 						...commonOptions.plugins.tooltip,
-						mode: "index",
-						intersect: false,
+						axis: "x",
 						positioner: (context) => {
 							const { chart, tooltipPosition } = context;
 							const { tooltip } = chart;
diff --git a/assets/js/components/Sessions/SolarGroupedChart.vue b/assets/js/components/Sessions/SolarGroupedChart.vue
index cc0894d6..4c374d04 100644
--- a/assets/js/components/Sessions/SolarGroupedChart.vue
+++ b/assets/js/components/Sessions/SolarGroupedChart.vue
@@ -92,8 +92,7 @@ export default {
 					...commonOptions.plugins,
 					tooltip: {
 						...commonOptions.plugins.tooltip,
-						intersect: false,
-						mode: "index",
+						axis: "r",
 						position: "topBottomCenter",
 						callbacks: {
 							title: () => null,
diff --git a/assets/js/components/Sessions/SolarYearChart.vue b/assets/js/components/Sessions/SolarYearChart.vue
index fbad38b0..1d60d4bc 100644
--- a/assets/js/components/Sessions/SolarYearChart.vue
+++ b/assets/js/components/Sessions/SolarYearChart.vue
@@ -147,8 +147,6 @@ export default {
 					...commonOptions.plugins,
 					tooltip: {
 						...commonOptions.plugins.tooltip,
-						intersect: false,
-						mode: "index",
 						axis: "xy",
 						position: "topBottomCenter",
 						callbacks: {
diff --git a/assets/js/components/Sessions/chartConfig.js b/assets/js/components/Sessions/chartConfig.js
index 0db9db15..a69cbc8b 100644
--- a/assets/js/components/Sessions/chartConfig.js
+++ b/assets/js/components/Sessions/chartConfig.js
@@ -45,6 +45,8 @@ export const commonOptions = {
       boxPadding: 5,
       usePointStyle: false,
       borderWidth: 0.00001,
+      mode: "index",
+      intersect: false,
     },
   },
 };
diff --git a/assets/js/components/Site.vue b/assets/js/components/Site.vue
index 57aad54d..a31777fb 100644
--- a/assets/js/components/Site.vue
+++ b/assets/js/components/Site.vue
@@ -67,7 +67,7 @@ export default {
 
 		// details
 		gridConfigured: Boolean,
-		gridPower: Number,
+		grid: Object,
 		homePower: Number,
 		pvPower: Number,
 		pv: Array,
@@ -113,6 +113,9 @@ export default {
 		pvConfigured: function () {
 			return this.pv?.length > 0;
 		},
+		gridPower: function () {
+			return this.grid?.power || 0;
+		},
 		energyflow: function () {
 			return this.collectProps(Energyflow);
 		},
diff --git a/assets/js/components/VehicleIcon/Battery.vue b/assets/js/components/VehicleIcon/Battery.vue
new file mode 100644
index 00000000..4ff7f037
--- /dev/null
+++ b/assets/js/components/VehicleIcon/Battery.vue
@@ -0,0 +1,14 @@
+<template>
+	<svg width="1em" height="1em" viewBox="0 0 24 24">
+		<path
+			fill="currentColor"
+			d="M8 22q-.425 0-.712-.288T7 21V5q0-.425.288-.712T8 4h2V3q0-.425.288-.712T11 2h2q.425 0 .713.288T14 3v1h2q.425 0 .713.288T17 5v16q0 .425-.288.713T16 22zm1-2h6V6H9zm0 0h6zm1-5.275q0 .575.213 1.088t.612.912l.225.225q.275.275.688.275t.712-.275q.3-.3.3-.712t-.3-.713l-.225-.225q-.125-.125-.175-.262T12 14.75q0-.175.05-.312t.175-.263l.95-.95q.4-.4.613-.9t.212-1.05q0-.575-.212-1.087t-.613-.913l-.25-.25q-.3-.3-.7-.288t-.7.313q-.275.3-.288.7t.288.7l.225.225q.125.125.188.262t.062.313q0 .15-.062.288t-.188.262l-.925.95q-.4.4-.612.9T10 14.725"
+		/>
+	</svg>
+</template>
+
+<script>
+export default {
+	name: "Battery",
+};
+</script>
diff --git a/assets/js/components/VehicleIcon/Bulb.vue b/assets/js/components/VehicleIcon/Bulb.vue
new file mode 100644
index 00000000..a6e0a86c
--- /dev/null
+++ b/assets/js/components/VehicleIcon/Bulb.vue
@@ -0,0 +1,14 @@
+<template>
+	<svg width="1em" height="1em" viewBox="0 0 24 24">
+		<path
+			fill="currentColor"
+			d="M11 21v-1q0-.425.288-.712T12 19t.713.288T13 20v1q0 .425-.288.713T12 22t-.712-.288T11 21M3 11h1q.425 0 .713.288T5 12t-.288.713T4 13H3q-.425 0-.712-.288T2 12t.288-.712T3 11m17 0h1q.425 0 .713.288T22 12t-.288.713T21 13h-1q-.425 0-.712-.288T19 12t.288-.712T20 11m-2.7 8.2l-.7-.7q-.275-.275-.275-.7t.275-.7t.7-.275t.7.275l.7.7q.275.275.275.7t-.275.7t-.7.275t-.7-.275m-12-1.4l.7-.7q.275-.275.7-.275t.7.275t.275.7t-.275.7l-.7.7q-.275.275-.7.275t-.7-.275t-.275-.7t.275-.7M12 17q-2.075 0-3.537-1.462T7 12q0-1.2.538-2.238T9 8V5q0-.825.588-1.412T11 3h2q.825 0 1.413.588T15 5v3q.925.725 1.463 1.763T17 12q0 2.075-1.463 3.538T12 17m-1-9.9q.25-.05.5-.075T12 7t.5.025t.5.075V5h-2zm1 7.9q1.25 0 2.125-.875T15 12t-.875-2.125T12 9t-2.125.875T9 12t.875 2.125T12 15m0-3"
+		/>
+	</svg>
+</template>
+
+<script>
+export default {
+	name: "Bulb",
+};
+</script>
diff --git a/assets/js/components/VehicleIcon/Coffeemaker.vue b/assets/js/components/VehicleIcon/Coffeemaker.vue
new file mode 100644
index 00000000..c6845550
--- /dev/null
+++ b/assets/js/components/VehicleIcon/Coffeemaker.vue
@@ -0,0 +1,14 @@
+<template>
+	<svg width="1em" height="1em" viewBox="0 0 24 24">
+		<path
+			fill="currentColor"
+			d="M6 22q-.825 0-1.412-.587T4 20V4q0-.825.588-1.412T6 2h13q.425 0 .713.288T20 3t-.288.713T19 4h-1v2q0 .425-.288.713T17 7H9q-.425 0-.712-.288T8 6V4H6v16h4.05q-.95-.675-1.5-1.713T8 16v-3q0-.825.588-1.412T10 11h6q.825 0 1.413.588T18 13v3q0 1.25-.55 2.288T15.95 20H19q.425 0 .713.288T20 21t-.288.713T19 22zm7-3q1.25 0 2.125-.875T16 16v-3h-6v3q0 1.25.875 2.125T13 19m0-9q.425 0 .713-.288T14 9t-.288-.712T13 8t-.712.288T12 9t.288.713T13 10m0 3"
+		/>
+	</svg>
+</template>
+
+<script>
+export default {
+	name: "Coffeemaker",
+};
+</script>
diff --git a/assets/js/components/VehicleIcon/Cooking.vue b/assets/js/components/VehicleIcon/Cooking.vue
new file mode 100644
index 00000000..0015a095
--- /dev/null
+++ b/assets/js/components/VehicleIcon/Cooking.vue
@@ -0,0 +1,14 @@
+<template>
+	<svg width="1em" height="1em" viewBox="0 0 24 24">
+		<path
+			fill="currentColor"
+			d="M3.725 7q-.65-.775-.937-1.475T2.475 4q0-.425.313-.712T3.525 3q.375 0 .613.3t.237.7q0 .5.175.925t.6.925q.75.9 1.063 1.613T6.5 9q-.025.425-.35.713T5.4 10q-.35 0-.562-.3t-.213-.675q0-.575-.238-1.05T3.726 7M7.75 7q-.65-.775-.95-1.475T6.475 4q0-.425.313-.712T7.525 3q.375 0 .613.3t.237.7q0 .5.175.925t.6.925q.75.9 1.063 1.613T10.5 9q-.025.425-.35.713T9.4 10q-.35 0-.562-.3t-.213-.675q.025-.575-.213-1.05T7.75 7m4 0q-.65-.775-.95-1.475T10.475 4q0-.425.313-.712T11.525 3q.375 0 .613.3t.237.7q0 .5.175.925t.6.925q.75.9 1.063 1.613T14.5 9q-.025.425-.35.713T13.4 10q-.35 0-.562-.3t-.213-.675q.025-.575-.213-1.05T11.75 7M5 20q-1.25 0-2.125-.875T2 17v-4q0-.425.288-.712T3 12h13.025q.125-.85.675-1.487t1.35-.913l3.675-1.225q.4-.125.775.05T23 9t-.062.775t-.588.5L18.675 11.5q-.3.1-.488.363T18 12.45V17q0 1.25-.875 2.125T15 20zm0-2h10q.425 0 .713-.288T16 17v-3H4v3q0 .425.288.713T5 18m5-2"
+		/>
+	</svg>
+</template>
+
+<script>
+export default {
+	name: "Cooking",
+};
+</script>
diff --git a/assets/js/components/VehicleIcon/Desktop.vue b/assets/js/components/VehicleIcon/Desktop.vue
new file mode 100644
index 00000000..f642b08e
--- /dev/null
+++ b/assets/js/components/VehicleIcon/Desktop.vue
@@ -0,0 +1,14 @@
+<template>
+	<svg width="1em" height="1em" viewBox="0 0 24 24">
+		<path
+			fill="currentColor"
+			d="M10 19v-2H4q-.825 0-1.412-.587T2 15V5q0-.825.588-1.412T4 3h16q.825 0 1.413.588T22 5v10q0 .825-.587 1.413T20 17h-6v2h1q.425 0 .713.288T16 20t-.288.713T15 21H9q-.425 0-.712-.288T8 20t.288-.712T9 19zm-6-4h16V5H4zm0 0V5z"
+		/>
+	</svg>
+</template>
+
+<script>
+export default {
+	name: "Desktop",
+};
+</script>
diff --git a/assets/js/components/VehicleIcon/Dryer.vue b/assets/js/components/VehicleIcon/Dryer.vue
new file mode 100644
index 00000000..d04cf911
--- /dev/null
+++ b/assets/js/components/VehicleIcon/Dryer.vue
@@ -0,0 +1,14 @@
+<template>
+	<svg width="1em" height="1em" viewBox="0 0 24 24">
+		<path
+			fill="currentColor"
+			d="M4 12.6q0-1.65.613-3.062T6.35 7.05l4.6-4.525q.225-.2.5-.312T12 2.1t.55.113t.5.312l4.6 4.525q.725.725 1.263 1.6t.812 1.875q.1.35-.137.613t-.588.337t-.75-.137t-.625-.788t-.562-1.1t-.813-.95L12 4.3L7.75 8.5q-.875.825-1.312 1.863T6 12.6q0 1.9 1.138 3.425t2.887 2.15q.575.2.775.6t.15.75t-.312.588t-.613.137Q7.4 19.575 5.7 17.463T4 12.6m13.375 2.9q-.45-.2-.913-.35T15.5 15q-.4 0-.775.088t-.725.237q-.275.125-.575.025T13 14.975q-.125-.3 0-.6t.425-.425q.5-.2 1.025-.325t1.05-.125q.575 0 1.138.138t1.087.337q.45.2.913.35t.962.15q.4 0 .775-.087t.725-.238q.275-.125.575-.025t.425.375q.125.3 0 .6t-.425.425q-.5.2-1.025.325t-1.05.125q-.575 0-1.137-.137t-1.088-.338m0 3q-.45-.2-.913-.35T15.5 18q-.4 0-.775.088t-.725.237q-.275.125-.575.025T13 17.975q-.125-.3 0-.6t.425-.425q.5-.2 1.025-.325t1.05-.125q.575 0 1.138.137t1.087.338q.45.2.913.35t.962.15q.4 0 .775-.088t.725-.237q.275-.125.575-.025t.425.375q.125.3 0 .6t-.425.425q-.5.2-1.025.325t-1.05.125q-.575 0-1.137-.137t-1.088-.338m0 3q-.45-.2-.913-.35T15.5 21q-.4 0-.775.088t-.725.237q-.275.125-.575.025T13 20.975q-.125-.3 0-.6t.425-.425q.5-.2 1.025-.325t1.05-.125q.575 0 1.138.137t1.087.338q.45.2.913.35t.962.15q.4 0 .775-.088t.725-.237q.275-.125.575-.025t.425.375q.125.3 0 .6t-.425.425q-.5.2-1.025.325t-1.05.125q-.575 0-1.137-.137t-1.088-.338"
+		/>
+	</svg>
+</template>
+
+<script>
+export default {
+	name: "Dryer",
+};
+</script>
diff --git a/assets/js/components/VehicleIcon/Floorlamp.vue b/assets/js/components/VehicleIcon/Floorlamp.vue
new file mode 100644
index 00000000..c96055be
--- /dev/null
+++ b/assets/js/components/VehicleIcon/Floorlamp.vue
@@ -0,0 +1,14 @@
+<template>
+	<svg width="1em" height="1em" viewBox="0 0 24 24">
+		<path
+			fill="currentColor"
+			d="M12 19q-.425 0-.712-.288T11 18v-7H6q-.5 0-.8-.4t-.15-.9L7 3.4q.2-.625.725-1.013T8.9 2h6.2q.65 0 1.175.388T17 3.4l1.95 6.3q.15.5-.15.9t-.8.4h-5v7q0 .425-.288.713T12 19M7.35 9h9.3L15.1 4H8.9zM9 22q-.425 0-.712-.288T8 21t.288-.712T9 20h6q.425 0 .713.288T16 21t-.288.713T15 22zm3-15.5"
+		/>
+	</svg>
+</template>
+
+<script>
+export default {
+	name: "Floorlamp",
+};
+</script>
diff --git a/assets/js/components/VehicleIcon/Heatexchange.vue b/assets/js/components/VehicleIcon/Heatexchange.vue
new file mode 100644
index 00000000..bca74a38
--- /dev/null
+++ b/assets/js/components/VehicleIcon/Heatexchange.vue
@@ -0,0 +1,14 @@
+<template>
+	<svg width="1em" height="1em" viewBox="0 0 24 24">
+		<path
+			fill="currentColor"
+			d="M20.175 7.6H19q-.425 0-.712.288T18 8.6v7q0 1.25-.875 2.125T15 18.6t-2.125-.875T12 15.6v-6q0-.425-.288-.712T11 8.6t-.712.288T10 9.6v6q0 1.25-.875 2.125T7 18.6t-2.125-.875T4 15.6v-8q0-.425.288-.712T5 6.6t.713.288T6 7.6v8q0 .425.288.713T7 16.6t.713-.288T8 15.6v-6q0-1.25.875-2.125T11 6.6t2.125.875T14 9.6v6q0 .425.288.713T15 16.6t.713-.288T16 15.6v-7q0-1.25.875-2.125T19 5.6h1.175l-.475-.475q-.275-.275-.275-.687t.275-.713q.3-.3.713-.3t.712.3L23.3 5.9q.3.3.3.7t-.3.7l-2.2 2.175q-.3.275-.713.288t-.687-.288t-.275-.725t.3-.7zM3 21.6q-.825 0-1.412-.587T1 19.6v-7q0-.425.288-.712T2 11.6h20q.425 0 .713.288T23 12.6v7q0 .825-.587 1.413T21 21.6zm0-2h18v-6H3zm18-6H3z"
+		/>
+	</svg>
+</template>
+
+<script>
+export default {
+	name: "Heatexchange",
+};
+</script>
diff --git a/assets/js/components/VehicleIcon/Kettle.vue b/assets/js/components/VehicleIcon/Kettle.vue
new file mode 100644
index 00000000..452a29e2
--- /dev/null
+++ b/assets/js/components/VehicleIcon/Kettle.vue
@@ -0,0 +1,14 @@
+<template>
+	<svg width="1em" height="1em" viewBox="0 0 24 24">
+		<path
+			fill="currentColor"
+			d="M6 17V6L4.2 3.6q-.375-.5-.1-1.05T5 2h10.775q.925 0 1.575.65T18 4.225V5h2q.825 0 1.413.588T22 7v5q0 .825-.587 1.413T20 14h-2v3q0 .825-.587 1.413T16 19H8q-.825 0-1.412-.587T6 17m2 0h8V4H7l1 1.3zm10-5h2V7h-2zm-4.5-7q-.625 0-1.062.438T12 6.5v8q0 .625.438 1.063T13.5 16t1.063-.437T15 14.5v-8q0-.625-.437-1.062T13.5 5M4 22q-.425 0-.712-.288T3 21t.288-.712T4 20h16q.425 0 .713.288T21 21t-.288.713T20 22zm7.5-11.5"
+		/>
+	</svg>
+</template>
+
+<script>
+export default {
+	name: "Kettle",
+};
+</script>
diff --git a/assets/js/components/VehicleIcon/Laundry2.vue b/assets/js/components/VehicleIcon/Laundry2.vue
new file mode 100644
index 00000000..f22b386e
--- /dev/null
+++ b/assets/js/components/VehicleIcon/Laundry2.vue
@@ -0,0 +1,14 @@
+<template>
+	<svg width="1em" height="1em" viewBox="0 0 24 24">
+		<path
+			fill="currentColor"
+			d="M4.875 9.3L8 7.575V13.5q-.525.05-1.025.163T6 13.975v-3l-1.025.55q-.35.2-.75.088t-.6-.463l-2-3.475q-.2-.35-.088-.762T2 6.3l4.975-2.875q.3-.175.625-.3T8.275 3t.6.213t.375.537q.35.95.913 1.6T12 6t1.838-.65t.912-1.6q.125-.325.388-.537T15.75 3t.663.125t.612.3L22 6.3q.35.2.45.6t-.1.75l-1.975 3.5q-.2.35-.6.463t-.75-.088L18 10.975v4.8l-1.575 1.375q-.1.075-.2.138T16 17.4V7.575L19.125 9.3l1-1.75L16.3 5.325q-.6 1.225-1.763 1.95T12 8t-2.537-.725T7.7 5.325L3.85 7.55zM4 18.625q-.275-.325-.238-.737t.363-.688l1.4-1.2q.575-.5 1.313-.763t1.537-.262t1.525.263t1.3.762l2.9 2.475q.3.25.713.388t.837.137q.45 0 .838-.125t.687-.4l1.4-1.2q.325-.275.738-.25t.687.35t.238.738t-.363.687l-1.4 1.2q-.575.5-1.3.75T15.65 21t-1.537-.25T12.8 20l-2.9-2.475q-.3-.25-.687-.387T8.375 17q-.425 0-.837.138t-.713.387l-1.425 1.2q-.325.275-.725.25T4 18.625"
+		/>
+	</svg>
+</template>
+
+<script>
+export default {
+	name: "Laundry2",
+};
+</script>
diff --git a/assets/js/components/VehicleIcon/Machine.vue b/assets/js/components/VehicleIcon/Machine.vue
new file mode 100644
index 00000000..269c7740
--- /dev/null
+++ b/assets/js/components/VehicleIcon/Machine.vue
@@ -0,0 +1,14 @@
+<template>
+	<svg width="1em" height="1em" viewBox="0 0 24 24">
+		<path
+			fill="currentColor"
+			d="M5.475 21q-.625 0-1.062-.437T3.975 19.5t.438-1.062T5.475 18h1.6l-2.55-8.35q-.675-.375-1.112-1.1T2.975 7q0-1.25.875-2.125T5.975 4q.975 0 1.738.563T8.775 6h3.2V5q0-.425.288-.712T12.975 4q.225 0 .438.1t.362.3l1.7-1.6q.225-.225.538-.288t.612.088l3.9 1.8q.3.15.413.438t-.013.562q-.15.3-.437.388t-.563-.038l-3.6-1.65l-2.35 2.2v1.4l2.35 2.15l3.6-1.65q.275-.125.575-.025t.425.375q.15.3.025.575t-.425.425l-3.9 1.85q-.3.15-.612.087t-.538-.287l-1.7-1.6q-.15.15-.362.275t-.438.125q-.425 0-.712-.287T11.975 9V8h-3.2q-.075.2-.162.375t-.238.375l5 9.25h2.1q.625 0 1.063.438t.437 1.062t-.437 1.063t-1.063.437zm.5-13q.425 0 .713-.288T6.975 7t-.287-.712T5.975 6t-.712.288T4.975 7t.288.713t.712.287m3.15 10h1.95l-4.3-8h-.1zm1.95 0"
+		/>
+	</svg>
+</template>
+
+<script>
+export default {
+	name: "Machine",
+};
+</script>
diff --git a/assets/js/components/VehicleIcon/Microwave.vue b/assets/js/components/VehicleIcon/Microwave.vue
new file mode 100644
index 00000000..c55624df
--- /dev/null
+++ b/assets/js/components/VehicleIcon/Microwave.vue
@@ -0,0 +1,14 @@
+<template>
+	<svg width="1em" height="1em" viewBox="0 0 24 24">
+		<path
+			fill="currentColor"
+			d="M4 20q-.825 0-1.412-.587T2 18V6q0-.825.588-1.412T4 4h16q.825 0 1.413.588T22 6v12q0 .825-.587 1.413T20 20zm0-2h16V6H4zm2-1h8q.425 0 .713-.288T15 16V8q0-.425-.288-.712T14 7H6q-.425 0-.712.288T5 8v8q0 .425.288.713T6 17m12 0q.425 0 .713-.288T19 16t-.288-.712T18 15t-.712.288T17 16t.288.713T18 17M7 15V9h6v6zm11-2q.425 0 .713-.288T19 12t-.288-.712T18 11t-.712.288T17 12t.288.713T18 13m0-4q.425 0 .713-.288T19 8t-.288-.712T18 7t-.712.288T17 8t.288.713T18 9M4 18V6z"
+		/>
+	</svg>
+</template>
+
+<script>
+export default {
+	name: "Microwave",
+};
+</script>
diff --git a/assets/js/components/VehicleIcon/Pump.vue b/assets/js/components/VehicleIcon/Pump.vue
new file mode 100644
index 00000000..6bbf4b13
--- /dev/null
+++ b/assets/js/components/VehicleIcon/Pump.vue
@@ -0,0 +1,14 @@
+<template>
+	<svg width="1em" height="1em" viewBox="0 0 24 24">
+		<path
+			fill="currentColor"
+			d="M12 15q-.825 0-1.412-.587T10 13q0-.575.238-1.137t.912-1.613l.425-.625q.15-.225.425-.225t.425.225l.425.625q.675 1.05.913 1.613T14 13q0 .825-.587 1.413T12 15m-9 2h4.1q-.425-.425-.787-.925T5.675 15H3zm9 0q2.075 0 3.538-1.463T17 12t-1.463-3.537T12 7T8.463 8.463T7 12t1.463 3.538T12 17m6.325-8H21V7h-4.1q.425.425.788.925T18.325 9M3 19q0 .425-.288.713T2 20t-.712-.288T1 19v-6q0-.425.288-.712T2 12t.713.288T3 13h2.075q-.05-.25-.062-.488T5 12q0-2.925 2.038-4.962T12 5h9q0-.425.288-.712T22 4t.713.288T23 5v6q0 .425-.288.713T22 12t-.712-.288T21 11h-2.075q.05.25.063.488T19 12q0 2.925-2.037 4.963T12 19zm0-2v-2zm18-8V7zm-9 3"
+		/>
+	</svg>
+</template>
+
+<script>
+export default {
+	name: "Pump",
+};
+</script>
diff --git a/assets/js/components/VehicleIcon/Rickshaw.vue b/assets/js/components/VehicleIcon/Rickshaw.vue
new file mode 100644
index 00000000..6de4410e
--- /dev/null
+++ b/assets/js/components/VehicleIcon/Rickshaw.vue
@@ -0,0 +1,14 @@
+<template>
+	<svg width="1em" height="1em" viewBox="0 0 24 24">
+		<path
+			fill="currentColor"
+			d="M6 17q-.975 0-1.737-.562T3.2 15H3q-.825 0-1.412-.587T1 13V5q0-.825.588-1.412T3 3h12.05q.45 0 .85.175t.7.525l3.95 4.75q.225.275.338.588T21 9.7v1.45q.875.3 1.438 1.088T23 14q0 1.25-.875 2.125T20 17q-.975 0-1.763-.562T17.15 15h-8.3q-.35.875-1.112 1.438T6 17M3 8h4V5H3zm6 5h5V5H9v3h2q.425 0 .713.288T12 9t-.288.713T11 10H9zm7-4h2.4L16 6.1zM6 15q.425 0 .713-.288T7 14t-.288-.712T6 13t-.712.288T5 14t.288.713T6 15m14 0q.425 0 .713-.288T21 14t-.288-.712T20 13t-.712.288T19 14t.288.713T20 15m-7.725 7.65L7.95 20.475q-.175-.1-.137-.288T8.05 20H11v-1.2q0-.275.238-.425t.487-.025l4.325 2.175q.175.1.138.288T15.95 21H13v1.2q0 .275-.238.425t-.487.025M3 10v3h.15q.35-.875 1.113-1.437T6 11q.275 0 .525.038T7 11.15V10zm13 3h1.15q.225-.65.713-1.137T19 11.15V11h-3zM3 10h4zm13 1h3z"
+		/>
+	</svg>
+</template>
+
+<script>
+export default {
+	name: "Rickshaw",
+};
+</script>
diff --git a/assets/js/components/VehicleIcon/Rocket.vue b/assets/js/components/VehicleIcon/Rocket.vue
new file mode 100644
index 00000000..7be8f75a
--- /dev/null
+++ b/assets/js/components/VehicleIcon/Rocket.vue
@@ -0,0 +1,14 @@
+<template>
+	<svg width="1em" height="1em" viewBox="0 0 24 24">
+		<path
+			fill="currentColor"
+			d="M7.1 11.35q.35-.7.725-1.35t.825-1.3l-1.4-.275l-2.1 2.1zm12.05-6.875q-1.75.05-3.737 1.025T11.8 8.1q-1.05 1.05-1.875 2.25T8.7 12.6l2.85 2.825q1.05-.4 2.25-1.225t2.25-1.875q1.625-1.625 2.6-3.6T19.675 5q0-.1-.038-.2t-.112-.175t-.175-.112t-.2-.038m-5.5 6q-.575-.575-.575-1.412t.575-1.413t1.425-.575t1.425.575t.575 1.413t-.575 1.412t-1.425.575t-1.425-.575m-.85 6.55L13.625 19l2.1-2.1l-.275-1.4q-.65.45-1.3.813t-1.35.712m8.775-13.35q.2 2.75-.9 5.363T17.2 14.025l.5 2.475q.1.5-.05.975t-.5.825L14 21.45q-.375.375-.9.288t-.725-.588l-1.525-3.575L6.575 13.3L3 11.775q-.5-.2-.6-.725t.275-.9L5.825 7q.35-.35.837-.5t.988-.05l2.475.5q2.375-2.375 4.988-3.475t5.362-.9q.2.025.4.113t.35.237t.238.35t.112.4m-17.65 12.3q.875-.875 2.138-.887t2.137.862t.863 2.138t-.888 2.137q-1.2 1.2-2.838 1.425t-3.287.45l.45-3.287q.225-1.637 1.425-2.838m1.425 1.4q-.425.425-.587 1.025T4.5 19.625q.625-.1 1.225-.25T6.75 18.8q.3-.3.325-.725T6.8 17.35t-.725-.288t-.725.313"
+		/>
+	</svg>
+</template>
+
+<script>
+export default {
+	name: "Rocket",
+};
+</script>
diff --git a/assets/js/components/VehicleIcon/Taxi.vue b/assets/js/components/VehicleIcon/Taxi.vue
new file mode 100644
index 00000000..35085120
--- /dev/null
+++ b/assets/js/components/VehicleIcon/Taxi.vue
@@ -0,0 +1,14 @@
+<template>
+	<svg width="1em" height="1em" viewBox="0 0 24 24">
+		<path
+			fill="currentColor"
+			d="M6 19v.5q0 .625-.437 1.063T4.5 21t-1.062-.437T3 19.5v-7.15q0-.175.025-.35t.075-.325L4.975 6.35q.2-.6.725-.975T6.875 5H9V4q0-.425.288-.712T10 3h4q.425 0 .713.288T15 4v1h2.125q.65 0 1.175.375t.725.975l1.875 5.325q.05.15.075.325t.025.35v7.15q0 .625-.437 1.063T19.5 21t-1.062-.437T18 19.5V19zm-.2-9h12.4l-1.05-3H6.85zM5 12v5zm2.5 4q.625 0 1.063-.437T9 14.5t-.437-1.062T7.5 13t-1.062.438T6 14.5t.438 1.063T7.5 16m9 0q.625 0 1.063-.437T18 14.5t-.437-1.062T16.5 13t-1.062.438T15 14.5t.438 1.063T16.5 16M5 17h14v-5H5z"
+		/>
+	</svg>
+</template>
+
+<script>
+export default {
+	name: "Taxi",
+};
+</script>
diff --git a/assets/js/components/VehicleIcon/VehicleIcon.story.vue b/assets/js/components/VehicleIcon/VehicleIcon.story.vue
index 37a8eba4..cd1bb92f 100644
--- a/assets/js/components/VehicleIcon/VehicleIcon.story.vue
+++ b/assets/js/components/VehicleIcon/VehicleIcon.story.vue
@@ -7,23 +7,39 @@ const icons = [
 	"bus",
 	"moped",
 	"motorcycle",
+	"rocket",
 	"scooter",
+	"taxi",
 	"tractor",
+	"rickshaw",
 	"shuttle",
 	"van",
-	"heater",
-	"heatpump",
-	"waterheater",
-	"laundry",
-	"dishwasher",
 	"airpurifier",
+	"battery",
+	"bulb",
 	"climate",
+	"coffeemaker",
+	"compute",
+	"cooking",
 	"cooler",
-	"generic",
+	"desktop",
 	"device",
+	"dishwasher",
+	"dryer",
+	"floorlamp",
+	"generic",
+	"heater",
+	"heatexchange",
+	"heatpump",
+	"kettle",
+	"laundry",
+	"laundry2",
+	"machine",
 	"meter",
-	"compute",
+	"microwave",
+	"pump",
 	"tool",
+	"waterheater",
 ];
 </script>
 
diff --git a/assets/js/components/VehicleIcon/VehicleIcon.vue b/assets/js/components/VehicleIcon/VehicleIcon.vue
index bf61803d..c3e42b2d 100644
--- a/assets/js/components/VehicleIcon/VehicleIcon.vue
+++ b/assets/js/components/VehicleIcon/VehicleIcon.vue
@@ -8,22 +8,38 @@ import "@h2d2/shopicons/es/regular/car3";
 import MultiIcon from "../MultiIcon";
 
 import airpurifier from "./Airpurifier.vue";
+import battery from "./Battery.vue";
 import bike from "./Bike.vue";
+import bulb from "./Bulb.vue";
 import bus from "./Bus.vue";
 import climate from "./Climate.vue";
+import coffeemaker from "./Coffeemaker.vue";
 import compute from "./Compute.vue";
+import cooking from "./Cooking.vue";
 import cooler from "./Cooler.vue";
+import desktop from "./Desktop.vue";
 import device from "./Device.vue";
 import dishwasher from "./Dishwasher.vue";
+import dryer from "./Dryer.vue";
+import floorlamp from "./Floorlamp.vue";
 import generic from "./Generic.vue";
 import heater from "./Heater.vue";
+import heatexchange from "./Heatexchange.vue";
 import heatpump from "./Heatpump.vue";
+import kettle from "./Kettle.vue";
 import laundry from "./Laundry.vue";
+import laundry2 from "./Laundry2.vue";
+import machine from "./Machine.vue";
 import meter from "./Meter.vue";
+import microwave from "./Microwave.vue";
 import moped from "./Moped.vue";
 import motorcycle from "./Motorcycle.vue";
+import pump from "./Pump.vue";
+import rickshaw from "./Rickshaw.vue";
+import rocket from "./Rocket.vue";
 import scooter from "./Scooter.vue";
 import shuttle from "./Shuttle.vue";
+import taxi from "./Taxi.vue";
 import tool from "./Tool.vue";
 import tractor from "./Tractor.vue";
 import van from "./Van.vue";
@@ -31,23 +47,39 @@ import waterheater from "./WaterHeater.vue";
 
 const icons = {
 	airpurifier,
+	battery,
 	bike,
+	bulb,
 	bus,
 	car: "shopicon-regular-car3",
 	climate,
+	coffeemaker,
 	compute,
+	cooking,
 	cooler,
+	desktop,
 	device,
 	dishwasher,
+	dryer,
+	floorlamp,
 	generic,
 	heater,
+	heatexchange,
 	heatpump,
+	kettle,
 	laundry,
+	laundry2,
+	machine,
 	meter,
+	microwave,
 	moped,
 	motorcycle,
+	pump,
+	rickshaw,
+	rocket,
 	scooter,
 	shuttle,
+	taxi,
 	tool,
 	tractor,
 	van,
diff --git a/charger/abl.go b/charger/abl.go
index 9a5953b2..c2f0f57c 100644
--- a/charger/abl.go
+++ b/charger/abl.go
@@ -3,7 +3,7 @@ package charger
 // LICENSE
 
 // Copyright (c) 2019-2022 andig
-// Copyright (c) 2022 premultiply
+// Copyright (c) 2022-2024 premultiply
 
 // This module is NOT covered by the MIT license. All rights reserved.
 
@@ -157,15 +157,20 @@ func (wb *ABLeMH) Status() (api.ChargeStatus, error) {
 		return api.StatusNone, err
 	}
 
-	r := rune(b[1]>>4-0x0A) + 'A'
+	s := string(rune((b[1]>>4)-0x0A) + 'A')
 
-	switch r {
-	case 'A', 'B', 'C':
-		return api.ChargeStatus(r), nil
+	switch s {
+	case "A", "B", "C":
+		return api.ChargeStatusString(s)
 	default:
+		// ensure Outlet is re-enabled after wake-up
+		if b[1] == 0xE0 { // Outlet is disabled
+			return api.StatusB, wb.set(ablRegModifyState, 0xA1A1)
+		}
+
 		status, ok := ablStatus[b[1]]
 		if !ok {
-			status = string(r)
+			status = s
 		}
 
 		return api.StatusNone, fmt.Errorf("invalid status: %s", status)
diff --git a/charger/bender.go b/charger/bender.go
index e7d5a0f7..911229b7 100644
--- a/charger/bender.go
+++ b/charger/bender.go
@@ -238,7 +238,7 @@ func (wb *BenderCC) totalEnergy() (float64, error) {
 		}
 
 		var total float64
-		for l := 0; l < 3; l++ {
+		for l := range 3 {
 			total += float64(binary.BigEndian.Uint32(b[4*l:4*(l+1)])) / 1e3
 		}
 
diff --git a/charger/config.go b/charger/config.go
index 6449c3d7..05b95ae9 100644
--- a/charger/config.go
+++ b/charger/config.go
@@ -2,14 +2,12 @@ package charger
 
 import (
 	"context"
-	"fmt"
-	"strings"
 
 	"github.com/evcc-io/evcc/api"
-	reg "github.com/evcc-io/evcc/util/registry"
+	"github.com/evcc-io/evcc/charger/config"
 )
 
-var registry = reg.New[api.Charger]("charger")
+var registry = config.Registry
 
 // Types returns the list of types
 func Types() []string {
@@ -18,15 +16,5 @@ func Types() []string {
 
 // NewFromConfig creates charger from configuration
 func NewFromConfig(ctx context.Context, typ string, other map[string]interface{}) (api.Charger, error) {
-	factory, err := registry.Get(strings.ToLower(typ))
-	if err != nil {
-		return nil, err
-	}
-
-	v, err := factory(ctx, other)
-	if err != nil {
-		err = fmt.Errorf("cannot create charger type '%s': %w", typ, err)
-	}
-
-	return v, err
+	return config.NewFromConfig(ctx, typ, other)
 }
diff --git a/charger/config/config.go b/charger/config/config.go
new file mode 100644
index 00000000..33a88b06
--- /dev/null
+++ b/charger/config/config.go
@@ -0,0 +1,27 @@
+package config
+
+import (
+	"context"
+	"fmt"
+	"strings"
+
+	"github.com/evcc-io/evcc/api"
+	reg "github.com/evcc-io/evcc/util/registry"
+)
+
+var Registry = reg.New[api.Charger]("charger")
+
+// NewFromConfig creates charger from configuration
+func NewFromConfig(ctx context.Context, typ string, other map[string]interface{}) (api.Charger, error) {
+	factory, err := Registry.Get(strings.ToLower(typ))
+	if err != nil {
+		return nil, err
+	}
+
+	v, err := factory(ctx, other)
+	if err != nil {
+		err = fmt.Errorf("cannot create charger type '%s': %w", typ, err)
+	}
+
+	return v, err
+}
diff --git a/charger/daheimladen-mb.go b/charger/daheimladen-mb.go
index 4f5397c1..15e50937 100644
--- a/charger/daheimladen-mb.go
+++ b/charger/daheimladen-mb.go
@@ -22,12 +22,11 @@ import (
 	"encoding/binary"
 	"fmt"
 	"time"
-	"unicode/utf16"
-	"unicode/utf8"
 
 	"github.com/evcc-io/evcc/api"
 	"github.com/evcc-io/evcc/util"
 	"github.com/evcc-io/evcc/util/modbus"
+	"golang.org/x/text/encoding/unicode"
 )
 
 // DaheimLadenMB charger implementation
@@ -143,19 +142,6 @@ func (wb *DaheimLadenMB) getCurrent() (uint16, error) {
 	return binary.BigEndian.Uint16(b), nil
 }
 
-// utf16BytesToString converts UTF-16 encoded bytes, in big or little endian byte order,
-// to a UTF-8 encoded string.
-func utf16BytesToString(b []byte, o binary.ByteOrder) string {
-	utf := make([]uint16, (len(b)+(2-1))/2)
-	for i := 0; i+(2-1) < len(b); i += 2 {
-		utf[i/2] = o.Uint16(b[i:])
-	}
-	if len(b)/2 < len(utf) {
-		utf[len(utf)-1] = utf8.RuneError
-	}
-	return string(utf16.Decode(utf))
-}
-
 // Status implements the api.Charger interface
 func (wb *DaheimLadenMB) Status() (api.ChargeStatus, error) {
 	b, err := wb.conn.ReadHoldingRegisters(dlRegChargingState, 1)
@@ -280,13 +266,18 @@ func (wb *DaheimLadenMB) Identify() (string, error) {
 	if err != nil {
 		return "", err
 	}
-	return utf16BytesToString(b, binary.BigEndian), nil
+	return unicode.UTF16(unicode.BigEndian, unicode.IgnoreBOM).NewDecoder().String(string(b))
 }
 
 var _ api.Diagnosis = (*DaheimLadenMB)(nil)
 
 // Diagnose implements the api.Diagnosis interface
 func (wb *DaheimLadenMB) Diagnose() {
+	utf16BytesToString := func(b []byte) string {
+		s, _ := unicode.UTF16(unicode.BigEndian, unicode.IgnoreBOM).NewDecoder().String(string(b))
+		return s
+	}
+
 	if b, err := wb.conn.ReadHoldingRegisters(dlRegChargingState, 1); err == nil {
 		fmt.Printf("\tCharging Station State:\t%d\n", binary.BigEndian.Uint16(b))
 	}
@@ -300,10 +291,10 @@ func (wb *DaheimLadenMB) Diagnose() {
 		fmt.Printf("\tCable Max. Current:\t%.1fA\n", float64(binary.BigEndian.Uint16(b)/10))
 	}
 	if b, err := wb.conn.ReadHoldingRegisters(dlRegStationId, 16); err == nil {
-		fmt.Printf("\tStation ID:\t%s\n", utf16BytesToString(b, binary.BigEndian))
+		fmt.Printf("\tStation ID:\t%s\n", utf16BytesToString(b))
 	}
 	if b, err := wb.conn.ReadHoldingRegisters(dlRegCardId, 16); err == nil {
-		fmt.Printf("\tCard ID:\t%s\n", utf16BytesToString(b, binary.BigEndian))
+		fmt.Printf("\tCard ID:\t%s\n", utf16BytesToString(b))
 	}
 	if b, err := wb.conn.ReadHoldingRegisters(dlRegSafeCurrent, 1); err == nil {
 		fmt.Printf("\tSafe Current:\t%.1fA\n", float64(binary.BigEndian.Uint16(b)/10))
diff --git a/charger/fritzdect.go b/charger/fritzdect.go
index 81a0fc9f..81cd659c 100644
--- a/charger/fritzdect.go
+++ b/charger/fritzdect.go
@@ -44,6 +44,9 @@ func NewFritzDECTFromConfig(other map[string]interface{}) (api.Charger, error) {
 // NewFritzDECT creates a new connection with standbypower for charger
 func NewFritzDECT(embed embed, uri, ain, user, password string, standbypower float64) (*FritzDECT, error) {
 	conn, err := fritzdect.NewConnection(uri, ain, user, password)
+	if err != nil {
+		return nil, err
+	}
 
 	c := &FritzDECT{
 		conn: conn,
@@ -51,7 +54,7 @@ func NewFritzDECT(embed embed, uri, ain, user, password string, standbypower flo
 
 	c.switchSocket = NewSwitchSocket(&embed, c.Enabled, c.conn.CurrentPower, standbypower)
 
-	return c, err
+	return c, nil
 }
 
 // Status implements the api.Charger interface
diff --git a/charger/go-e.go b/charger/go-e.go
index 6418ce86..4f4b3aa0 100644
--- a/charger/go-e.go
+++ b/charger/go-e.go
@@ -38,13 +38,18 @@ type GoE struct {
 }
 
 func init() {
-	registry.Add("go-e", NewGoEFromConfig)
+	registry.Add("go-e", func(other map[string]interface{}) (api.Charger, error) {
+		return newGoEFromConfig(true, other)
+	})
+	registry.Add("go-e-v3", func(other map[string]interface{}) (api.Charger, error) {
+		return newGoEFromConfig(false, other)
+	})
 }
 
-//go:generate decorate -f decorateGoE -b *GoE -r api.Charger -t "api.PhaseSwitcher,Phases1p3p,func(int) error"
+//go:generate decorate -f decorateGoE -b *GoE -r api.Charger -t "api.ChargeRater,ChargedEnergy,func() (float64, error)" -t "api.PhaseSwitcher,Phases1p3p,func(int) error"
 
-// NewGoEFromConfig creates a go-e charger from generic config
-func NewGoEFromConfig(other map[string]interface{}) (api.Charger, error) {
+// newGoEFromConfig creates a go-e charger from generic config
+func newGoEFromConfig(v2 bool, other map[string]interface{}) (api.Charger, error) {
 	cc := struct {
 		Token string
 		URI   string
@@ -61,11 +66,26 @@ func NewGoEFromConfig(other map[string]interface{}) (api.Charger, error) {
 		return nil, errors.New("must have either uri or token")
 	}
 
-	return NewGoE(cc.URI, cc.Token, cc.Cache)
+	c, err := NewGoE(cc.URI, cc.Token, cc.Cache)
+	if err != nil {
+		return nil, err
+	}
+
+	var chargedEnergy func() (float64, error)
+	if v2 {
+		chargedEnergy = c.chargedEnergy
+	}
+
+	var phases1p3p func(int) error
+	if c.api.IsV2() {
+		phases1p3p = c.phases1p3p
+	}
+
+	return decorateGoE(c, chargedEnergy, phases1p3p), nil
 }
 
 // NewGoE creates GoE charger
-func NewGoE(uri, token string, cache time.Duration) (api.Charger, error) {
+func NewGoE(uri, token string, cache time.Duration) (*GoE, error) {
 	c := &GoE{}
 
 	log := util.NewLogger("go-e").Redact(token)
@@ -80,10 +100,6 @@ func NewGoE(uri, token string, cache time.Duration) (api.Charger, error) {
 		return nil, api.ErrSponsorRequired
 	}
 
-	if c.api.IsV2() {
-		return decorateGoE(c, c.phases1p3p), nil
-	}
-
 	return c, nil
 }
 
@@ -203,6 +219,17 @@ func (c *GoE) TotalEnergy() (float64, error) {
 	return resp.TotalEnergy(), err
 }
 
+// chargedEnergy implements the api.ChargeRater interface - v2 only
+// https://github.com/evcc-io/evcc/issues/13726
+func (c *GoE) chargedEnergy() (float64, error) {
+	resp, err := c.api.Status()
+	if err != nil {
+		return 0, err
+	}
+
+	return resp.ChargedEnergy(), err
+}
+
 // phases1p3p implements the api.PhaseSwitcher interface - v2 only
 func (c *GoE) phases1p3p(phases int) error {
 	if phases == 3 {
diff --git a/charger/go-e_decorators.go b/charger/go-e_decorators.go
index 43aac409..ed0a7249 100644
--- a/charger/go-e_decorators.go
+++ b/charger/go-e_decorators.go
@@ -6,17 +6,43 @@ import (
 	"github.com/evcc-io/evcc/api"
 )
 
-func decorateGoE(base *GoE, phaseSwitcher func(int) error) api.Charger {
+func decorateGoE(base *GoE, chargeRater func() (float64, error), phaseSwitcher func(int) error) api.Charger {
 	switch {
-	case phaseSwitcher == nil:
+	case chargeRater == nil && phaseSwitcher == nil:
 		return base
 
-	case phaseSwitcher != nil:
+	case chargeRater != nil && phaseSwitcher == nil:
 		return &struct {
 			*GoE
+			api.ChargeRater
+		}{
+			GoE: base,
+			ChargeRater: &decorateGoEChargeRaterImpl{
+				chargeRater: chargeRater,
+			},
+		}
+
+	case chargeRater == nil && phaseSwitcher != nil:
+		return &struct {
+			*GoE
+			api.PhaseSwitcher
+		}{
+			GoE: base,
+			PhaseSwitcher: &decorateGoEPhaseSwitcherImpl{
+				phaseSwitcher: phaseSwitcher,
+			},
+		}
+
+	case chargeRater != nil && phaseSwitcher != nil:
+		return &struct {
+			*GoE
+			api.ChargeRater
 			api.PhaseSwitcher
 		}{
 			GoE: base,
+			ChargeRater: &decorateGoEChargeRaterImpl{
+				chargeRater: chargeRater,
+			},
 			PhaseSwitcher: &decorateGoEPhaseSwitcherImpl{
 				phaseSwitcher: phaseSwitcher,
 			},
@@ -26,6 +52,14 @@ func decorateGoE(base *GoE, phaseSwitcher func(int) error) api.Charger {
 	return nil
 }
 
+type decorateGoEChargeRaterImpl struct {
+	chargeRater func() (float64, error)
+}
+
+func (impl *decorateGoEChargeRaterImpl) ChargedEnergy() (float64, error) {
+	return impl.chargeRater()
+}
+
 type decorateGoEPhaseSwitcherImpl struct {
 	phaseSwitcher func(int) error
 }
diff --git a/charger/homematic.go b/charger/homematic.go
index 30c547f3..ec2a7382 100644
--- a/charger/homematic.go
+++ b/charger/homematic.go
@@ -44,6 +44,9 @@ func NewCCUFromConfig(other map[string]interface{}) (api.Charger, error) {
 // NewCCU creates a new connection with standbypower for charger
 func NewCCU(embed embed, uri, deviceid, meterid, switchid, user, password string, standbypower float64, cache time.Duration) (*CCU, error) {
 	conn, err := homematic.NewConnection(uri, deviceid, meterid, switchid, user, password, cache)
+	if err != nil {
+		return nil, err
+	}
 
 	c := &CCU{
 		conn: conn,
@@ -51,7 +54,7 @@ func NewCCU(embed embed, uri, deviceid, meterid, switchid, user, password string
 
 	c.switchSocket = NewSwitchSocket(&embed, c.Enabled, c.conn.CurrentPower, standbypower)
 
-	return c, err
+	return c, nil
 }
 
 // Enabled implements the api.Charger interface
diff --git a/charger/mypv-elwa2.go b/charger/mypv.go
similarity index 63%
rename from charger/mypv-elwa2.go
rename to charger/mypv.go
index 151e2f6a..db9da992 100644
--- a/charger/mypv-elwa2.go
+++ b/charger/mypv.go
@@ -29,11 +29,12 @@ import (
 	"github.com/evcc-io/evcc/util/sponsor"
 )
 
-// MyPvElwa2 charger implementation
-type MyPvElwa2 struct {
-	log   *util.Logger
-	conn  *modbus.Connection
-	power uint32
+// MyPv charger implementation
+type MyPv struct {
+	log     *util.Logger
+	conn    *modbus.Connection
+	power   uint32
+	statusC uint16
 }
 
 const (
@@ -41,17 +42,23 @@ const (
 	elwaRegTemp      = 1001
 	elwaRegTempLimit = 1002
 	elwaRegStatus    = 1003
-	elwaRegPower     = 1074
+	elwaRegPower     = 1000 // https://github.com/evcc-io/evcc/issues/18020#issuecomment-2585300804
 )
 
 func init() {
-	registry.AddCtx("ac-elwa-2", NewMyPvElwa2FromConfig)
-}
+	// https://github.com/evcc-io/evcc/discussions/12761
+	registry.AddCtx("ac-elwa-2", func(ctx context.Context, other map[string]interface{}) (api.Charger, error) {
+		return newMyPvFromConfig(ctx, "ac-elwa-2", other, 2)
+	})
 
-// https://github.com/evcc-io/evcc/discussions/12761
+	// https: // github.com/evcc-io/evcc/issues/18020
+	registry.AddCtx("ac-thor", func(ctx context.Context, other map[string]interface{}) (api.Charger, error) {
+		return newMyPvFromConfig(ctx, "ac-thor", other, 9)
+	})
+}
 
-// NewMyPvElwa2FromConfig creates a MyPvElwa2 charger from generic config
-func NewMyPvElwa2FromConfig(ctx context.Context, other map[string]interface{}) (api.Charger, error) {
+// newMyPvFromConfig creates a MyPv charger from generic config
+func newMyPvFromConfig(ctx context.Context, name string, other map[string]interface{}, statusC uint16) (api.Charger, error) {
 	cc := modbus.TcpSettings{
 		ID: 1,
 	}
@@ -60,11 +67,11 @@ func NewMyPvElwa2FromConfig(ctx context.Context, other map[string]interface{}) (
 		return nil, err
 	}
 
-	return NewMyPvElwa2(ctx, cc.URI, cc.ID)
+	return NewMyPv(ctx, name, cc.URI, cc.ID, statusC)
 }
 
-// NewMyPvElwa2 creates myPV AC Elwa 2 charger
-func NewMyPvElwa2(ctx context.Context, uri string, slaveID uint8) (api.Charger, error) {
+// NewMyPv creates myPV AC Elwa 2 or Thor charger
+func NewMyPv(ctx context.Context, name, uri string, slaveID uint8, statusC uint16) (api.Charger, error) {
 	conn, err := modbus.NewConnection(uri, "", "", 0, modbus.Tcp, slaveID)
 	if err != nil {
 		return nil, err
@@ -74,12 +81,13 @@ func NewMyPvElwa2(ctx context.Context, uri string, slaveID uint8) (api.Charger,
 		return nil, api.ErrSponsorRequired
 	}
 
-	log := util.NewLogger("ac-elwa-2")
+	log := util.NewLogger(name)
 	conn.Logger(log.TRACE)
 
-	wb := &MyPvElwa2{
-		log:  log,
-		conn: conn,
+	wb := &MyPv{
+		log:     log,
+		conn:    conn,
+		statusC: statusC,
 	}
 
 	go wb.heartbeat(ctx, 30*time.Second)
@@ -87,21 +95,21 @@ func NewMyPvElwa2(ctx context.Context, uri string, slaveID uint8) (api.Charger,
 	return wb, nil
 }
 
-var _ api.IconDescriber = (*MyPvElwa2)(nil)
+var _ api.IconDescriber = (*MyPv)(nil)
 
 // Icon implements the api.IconDescriber interface
-func (v *MyPvElwa2) Icon() string {
+func (v *MyPv) Icon() string {
 	return "waterheater"
 }
 
-var _ api.FeatureDescriber = (*MyPvElwa2)(nil)
+var _ api.FeatureDescriber = (*MyPv)(nil)
 
 // Features implements the api.FeatureDescriber interface
-func (wb *MyPvElwa2) Features() []api.Feature {
+func (wb *MyPv) Features() []api.Feature {
 	return []api.Feature{api.IntegratedDevice, api.Heating}
 }
 
-func (wb *MyPvElwa2) heartbeat(ctx context.Context, timeout time.Duration) {
+func (wb *MyPv) heartbeat(ctx context.Context, timeout time.Duration) {
 	for tick := time.Tick(timeout); ; {
 		select {
 		case <-tick:
@@ -122,15 +130,14 @@ func (wb *MyPvElwa2) heartbeat(ctx context.Context, timeout time.Duration) {
 }
 
 // Status implements the api.Charger interface
-func (wb *MyPvElwa2) Status() (api.ChargeStatus, error) {
-	res := api.StatusA
+func (wb *MyPv) Status() (api.ChargeStatus, error) {
 	b, err := wb.conn.ReadHoldingRegisters(elwaRegStatus, 1)
 	if err != nil {
-		return res, err
+		return api.StatusNone, err
 	}
 
-	res = api.StatusB
-	if binary.BigEndian.Uint16(b) == 2 {
+	res := api.StatusB
+	if binary.BigEndian.Uint16(b) == wb.statusC {
 		res = api.StatusC
 	}
 
@@ -138,7 +145,7 @@ func (wb *MyPvElwa2) Status() (api.ChargeStatus, error) {
 }
 
 // Enabled implements the api.Charger interface
-func (wb *MyPvElwa2) Enabled() (bool, error) {
+func (wb *MyPv) Enabled() (bool, error) {
 	b, err := wb.conn.ReadHoldingRegisters(elwaRegSetPower, 1)
 	if err != nil {
 		return false, err
@@ -147,7 +154,7 @@ func (wb *MyPvElwa2) Enabled() (bool, error) {
 	return binary.BigEndian.Uint16(b) > 0, nil
 }
 
-func (wb *MyPvElwa2) setPower(power uint16) error {
+func (wb *MyPv) setPower(power uint16) error {
 	b := make([]byte, 2)
 	binary.BigEndian.PutUint16(b, power)
 
@@ -156,7 +163,7 @@ func (wb *MyPvElwa2) setPower(power uint16) error {
 }
 
 // Enable implements the api.Charger interface
-func (wb *MyPvElwa2) Enable(enable bool) error {
+func (wb *MyPv) Enable(enable bool) error {
 	var power uint16
 	if enable {
 		power = uint16(atomic.LoadUint32(&wb.power))
@@ -166,14 +173,14 @@ func (wb *MyPvElwa2) Enable(enable bool) error {
 }
 
 // MaxCurrent implements the api.Charger interface
-func (wb *MyPvElwa2) MaxCurrent(current int64) error {
+func (wb *MyPv) MaxCurrent(current int64) error {
 	return wb.MaxCurrentMillis(float64(current))
 }
 
-var _ api.ChargerEx = (*MyPvElwa2)(nil)
+var _ api.ChargerEx = (*MyPv)(nil)
 
 // MaxCurrentMillis implements the api.ChargerEx interface
-func (wb *MyPvElwa2) MaxCurrentMillis(current float64) error {
+func (wb *MyPv) MaxCurrentMillis(current float64) error {
 	power := uint16(230 * current)
 
 	err := wb.setPower(power)
@@ -184,10 +191,10 @@ func (wb *MyPvElwa2) MaxCurrentMillis(current float64) error {
 	return err
 }
 
-var _ api.Meter = (*MyPvElwa2)(nil)
+var _ api.Meter = (*MyPv)(nil)
 
 // CurrentPower implements the api.Meter interface
-func (wb *MyPvElwa2) CurrentPower() (float64, error) {
+func (wb *MyPv) CurrentPower() (float64, error) {
 	b, err := wb.conn.ReadHoldingRegisters(elwaRegPower, 1)
 	if err != nil {
 		return 0, err
@@ -196,10 +203,10 @@ func (wb *MyPvElwa2) CurrentPower() (float64, error) {
 	return float64(binary.BigEndian.Uint16(b)), nil
 }
 
-var _ api.Battery = (*MyPvElwa2)(nil)
+var _ api.Battery = (*MyPv)(nil)
 
 // CurrentPower implements the api.Meter interface
-func (wb *MyPvElwa2) Soc() (float64, error) {
+func (wb *MyPv) Soc() (float64, error) {
 	b, err := wb.conn.ReadHoldingRegisters(elwaRegTemp, 1)
 	if err != nil {
 		return 0, err
@@ -208,10 +215,10 @@ func (wb *MyPvElwa2) Soc() (float64, error) {
 	return float64(binary.BigEndian.Uint16(b)) / 10, nil
 }
 
-var _ api.SocLimiter = (*MyPvElwa2)(nil)
+var _ api.SocLimiter = (*MyPv)(nil)
 
 // GetLimitSoc implements the api.SocLimiter interface
-func (wb *MyPvElwa2) GetLimitSoc() (int64, error) {
+func (wb *MyPv) GetLimitSoc() (int64, error) {
 	b, err := wb.conn.ReadHoldingRegisters(elwaRegTempLimit, 1)
 	if err != nil {
 		return 0, err
diff --git a/charger/peblar.go b/charger/peblar.go
index 9bbae2b9..f2410ab3 100644
--- a/charger/peblar.go
+++ b/charger/peblar.go
@@ -233,7 +233,7 @@ func (wb *Peblar) getPhaseValues(reg uint16, divider float64) (float64, float64,
 	}
 
 	var res [3]float64
-	for i := 0; i < int(wb.phases); i++ {
+	for i := range int(wb.phases) {
 		res[i] = float64(binary.BigEndian.Uint32(b[4*i:])) / divider
 	}
 
diff --git a/charger/vaillant.go b/charger/vaillant.go
index 0911b51d..83f80bc9 100644
--- a/charger/vaillant.go
+++ b/charger/vaillant.go
@@ -71,16 +71,17 @@ func NewVaillantFromConfig(ctx context.Context, other map[string]interface{}) (a
 	}
 
 	log := util.NewLogger("vaillant").Redact(cc.User, cc.Password)
-	client := request.NewClient(log)
-	clientCtx := context.WithValue(ctx, oauth2.HTTPClient, client)
+	logCtx := context.WithValue(ctx, oauth2.HTTPClient, request.NewClient(log))
 
 	oc := sensonet.Oauth2ConfigForRealm(cc.Realm)
-	token, err := oc.PasswordCredentialsToken(clientCtx, cc.User, cc.Password)
+	token, err := oc.PasswordCredentialsToken(logCtx, cc.User, cc.Password)
 	if err != nil {
 		return nil, err
 	}
 
-	conn, err := sensonet.NewConnection(oc.TokenSource(clientCtx, token), sensonet.WithHttpClient(client), sensonet.WithLogger(log.TRACE))
+	conn, err := sensonet.NewConnection(oc.TokenSource(logCtx, token),
+		sensonet.WithHttpClient(request.NewClient(log)),
+		sensonet.WithLogger(log.TRACE))
 	if err != nil {
 		return nil, err
 	}
@@ -184,7 +185,7 @@ func (v *Vaillant) print(chapter int, prefix string, zz ...any) {
 		fmt.Printf("%d.%d. %s\n", chapter, i+1, typ)
 
 		if rt.Kind() == reflect.Slice {
-			for j := 0; j < rv.Len(); j++ {
+			for j := range rv.Len() {
 				fmt.Printf("%d.%d.%d. %s %d\n", chapter, i+1, j+1, typ, j)
 				fmt.Printf("%+v\n", rv.Index(j))
 			}
diff --git a/charger/versicharge.go b/charger/versicharge.go
index 8251d435..90670c29 100644
--- a/charger/versicharge.go
+++ b/charger/versicharge.go
@@ -148,7 +148,7 @@ func (wb *Versicharge) CurrentPower() (float64, error) {
 	}
 
 	var sum float64
-	for i := 0; i < 3; i++ {
+	for i := range 3 {
 		sum += float64(binary.BigEndian.Uint16(b[2*i:]))
 	}
 
diff --git "a/charger/weidm\303\274ller.go" "b/charger/weidm\303\274ller.go"
index efebcd11..9afbae91 100644
--- "a/charger/weidm\303\274ller.go"
+++ "b/charger/weidm\303\274ller.go"
@@ -2,7 +2,7 @@ package charger
 
 // LICENSE
 
-// Copyright (c) 2023 premultiply
+// Copyright (c) 2023-2025 premultiply
 
 // This module is NOT covered by the MIT license. All rights reserved.
 
@@ -18,8 +18,10 @@ package charger
 // SOFTWARE.
 
 import (
+	"context"
 	"encoding/binary"
 	"fmt"
+	"time"
 
 	"github.com/evcc-io/evcc/api"
 	"github.com/evcc-io/evcc/util"
@@ -36,23 +38,27 @@ type Weidmüller struct {
 }
 
 const (
-	wmRegCarStatus    = 301  // GD_ID_EVCC_CAR_STATE CHAR
-	wmRegEvccStatus   = 302  // GD_ID_EVCC_EVSE_STATE UINT16
-	wmRegPhases       = 318  // GD_ID_EVCC_PHASES_LLM UINT16
-	wmRegVoltages     = 400  // GD_ID_CM_VOLTAGE_PHASE UINT32
-	wmRegCurrents     = 406  // GD_ID_CM_CURRENT_PHASE UINT32
-	wmRegActivePower  = 418  // GD_ID_CM_ACTIVE_POWER UINT32
-	wmRegTotalEnergy  = 457  // GD_ID_CM_CONSUMED_ENERGY_TOTAL_WH UINT64
-	wmRegCurrentLimit = 702  // GD_ID_AUT_USER_CURRENT_LIMIT UINT16
-	wmRegCardId       = 1000 // GD_ID_RFID_TAG_UID CHAR[21]
+	wmRegCarStatus    = 301   // GD_ID_EVCC_CAR_STATE CHAR
+	wmRegEvccStatus   = 302   // GD_ID_EVCC_EVSE_STATE UINT16
+	wmRegPhases       = 318   // GD_ID_EVCC_PHASES_LLM UINT16
+	wmRegVoltages     = 400   // GD_ID_CM_VOLTAGE_PHASE UINT32
+	wmRegCurrents     = 406   // GD_ID_CM_CURRENT_PHASE UINT32
+	wmRegActivePower  = 418   // GD_ID_CM_ACTIVE_POWER UINT32
+	wmRegTotalEnergy  = 457   // GD_ID_CM_CONSUMED_ENERGY_TOTAL_WH UINT64
+	wmRegCardId       = 1000  // GD_ID_RFID_TAG_UID CHAR[21]
+	wmRegTimeout      = 11050 // GD_ID_LCM_TIMEOUT UINT32
+	wmRegCurrentLimit = 11052 // GD_ID_LCM_ACTUAL_CURRENT_LIMIT UINT16 (A)
+
+	wmHeartbeatInterval = 5 * time.Second
+	wmTimeout           = 65535 // ms
 )
 
 func init() {
-	registry.Add("weidmüller", NewWeidmüllerFromConfig)
+	registry.AddCtx("weidmüller", NewWeidmüllerFromConfig)
 }
 
 // NewWeidmüllerFromConfig creates a Weidmüller charger from generic config
-func NewWeidmüllerFromConfig(other map[string]interface{}) (api.Charger, error) {
+func NewWeidmüllerFromConfig(ctx context.Context, other map[string]interface{}) (api.Charger, error) {
 	cc := modbus.TcpSettings{
 		ID: 255,
 	}
@@ -61,11 +67,13 @@ func NewWeidmüllerFromConfig(other map[string]interface{}) (api.Charger, error)
 		return nil, err
 	}
 
-	return NewWeidmüller(cc.URI, cc.ID)
+	return NewWeidmüller(ctx, cc.URI, cc.ID)
 }
 
+//go:generate decorate -f decorateWeidmüller -b *Weidmüller -r api.Charger -t "api.MeterEnergy,TotalEnergy,func() (float64, error)"
+
 // NewWeidmüller creates Weidmüller charger
-func NewWeidmüller(uri string, id uint8) (api.Charger, error) {
+func NewWeidmüller(ctx context.Context, uri string, id uint8) (api.Charger, error) {
 	conn, err := modbus.NewConnection(uri, "", "", 0, modbus.Tcp, id)
 	if err != nil {
 		return nil, err
@@ -93,7 +101,32 @@ func NewWeidmüller(uri string, id uint8) (api.Charger, error) {
 		wb.curr = curr
 	}
 
-	return wb, err
+	// failsafe
+	go wb.heartbeat(ctx, wmHeartbeatInterval)
+
+	// check presence of energy meter
+	if b, err := wb.conn.ReadHoldingRegisters(wmRegTotalEnergy, 2); err == nil && binary.BigEndian.Uint32(b) > 0 {
+		return decorateWeidmüller(wb, wb.totalEnergy), nil
+	}
+
+	return wb, nil
+}
+
+func (wb *Weidmüller) heartbeat(ctx context.Context, timeout time.Duration) {
+	b := make([]byte, 4)
+	binary.BigEndian.PutUint32(b, wmTimeout)
+
+	for tick := time.Tick(timeout); ; {
+		select {
+		case <-tick:
+		case <-ctx.Done():
+			return
+		}
+
+		if _, err := wb.conn.WriteMultipleRegisters(wmRegTimeout, 2, b); err != nil {
+			wb.log.ERROR.Println("heartbeat:", err)
+		}
+	}
 }
 
 func (wb *Weidmüller) setCurrent(current uint16) error {
@@ -184,10 +217,8 @@ func (wb *Weidmüller) CurrentPower() (float64, error) {
 	return float64(encoding.Uint32LswFirst(b)) / 1e3, err
 }
 
-var _ api.MeterEnergy = (*Weidmüller)(nil)
-
 // TotalEnergy implements the api.MeterEnergy interface
-func (wb *Weidmüller) TotalEnergy() (float64, error) {
+func (wb *Weidmüller) totalEnergy() (float64, error) {
 	b, err := wb.conn.ReadHoldingRegisters(wmRegTotalEnergy, 2)
 	if err != nil {
 		return 0, err
diff --git "a/charger/weidm\303\274ller_decorators.go" "b/charger/weidm\303\274ller_decorators.go"
new file mode 100644
index 00000000..1df75453
--- /dev/null
+++ "b/charger/weidm\303\274ller_decorators.go"
@@ -0,0 +1,35 @@
+package charger
+
+// Code generated by github.com/evcc-io/evcc/cmd/tools/decorate.go. DO NOT EDIT.
+
+import (
+	"github.com/evcc-io/evcc/api"
+)
+
+func decorateWeidmüller(base *Weidmüller, meterEnergy func() (float64, error)) api.Charger {
+	switch {
+	case meterEnergy == nil:
+		return base
+
+	case meterEnergy != nil:
+		return &struct {
+			*Weidmüller
+			api.MeterEnergy
+		}{
+			Weidmüller: base,
+			MeterEnergy: &decorateWeidmüllerMeterEnergyImpl{
+				meterEnergy: meterEnergy,
+			},
+		}
+	}
+
+	return nil
+}
+
+type decorateWeidmüllerMeterEnergyImpl struct {
+	meterEnergy func() (float64, error)
+}
+
+func (impl *decorateWeidmüllerMeterEnergyImpl) TotalEnergy() (float64, error) {
+	return impl.meterEnergy()
+}
diff --git a/cmd/demo.yaml b/cmd/demo.yaml
index acca352b..c54a5b7e 100644
--- a/cmd/demo.yaml
+++ b/cmd/demo.yaml
@@ -263,5 +263,6 @@ tariffs:
     type: fixed
     price: 0.08 # EUR/kWh
   co2:
-    type: grünstromindex
+    type: template
+    template: grünstromindex
     zip: 12349
diff --git a/cmd/detect/work.go b/cmd/detect/work.go
index ac8c1c56..e5e65dc7 100644
--- a/cmd/detect/work.go
+++ b/cmd/detect/work.go
@@ -13,7 +13,7 @@ import (
 
 func workers(log *util.Logger, num int, tasks <-chan string, hits chan<- []tasks.Result) *sync.WaitGroup {
 	var wg sync.WaitGroup
-	for i := 0; i < num; i++ {
+	for range num {
 		wg.Add(1)
 		go func() {
 			workunit(log, tasks, hits)
diff --git a/core/keys/site.go b/core/keys/site.go
index 4ffbb147..b121cf09 100644
--- a/core/keys/site.go
+++ b/core/keys/site.go
@@ -3,14 +3,13 @@ package keys
 const (
 	Aux                   = "aux"
 	AuxPower              = "auxPower"
+	Circuits              = "circuits"
 	Currency              = "currency"
+	Ext                   = "ext"
 	GreenShareHome        = "greenShareHome"
 	GreenShareLoadpoints  = "greenShareLoadpoints"
 	GridConfigured        = "gridConfigured"
-	GridCurrents          = "gridCurrents"
-	GridEnergy            = "gridEnergy"
-	GridPower             = "gridPower"
-	GridPowers            = "gridPowers"
+	Grid                  = "grid"
 	HomePower             = "homePower"
 	PrioritySoc           = "prioritySoc"
 	Pv                    = "pv"
@@ -28,8 +27,6 @@ const (
 	TariffPriceHome       = "tariffPriceHome"
 	TariffPriceLoadpoints = "tariffPriceLoadpoints"
 	Vehicles              = "vehicles"
-	Circuits              = "circuits"
-	Ext                   = "ext"
 
 	// meters
 	GridMeter     = "gridMeter"
diff --git a/core/site.go b/core/site.go
index 1e1b2df0..3ed034f1 100644
--- a/core/site.go
+++ b/core/site.go
@@ -42,20 +42,16 @@ type updater interface {
 	Update(sitePower, batteryBoostPower float64, rates api.Rates, batteryBuffered, batteryStart bool, greenShare float64, effectivePrice, effectiveCo2 *float64)
 }
 
-// meterMeasurement is used as slice element for publishing structured data
-type meterMeasurement struct {
-	Power         float64 `json:"power"`
-	Energy        float64 `json:"energy,omitempty"`
-	ExcessDCPower float64 `json:"excessdcpower,omitempty"`
-}
-
-// batteryMeasurement is used as slice element for publishing structured data
-type batteryMeasurement struct {
-	Power        float64 `json:"power"`
-	Energy       float64 `json:"energy,omitempty"`
-	Soc          float64 `json:"soc,omitempty"`
-	Capacity     float64 `json:"capacity,omitempty"`
-	Controllable bool    `json:"controllable"`
+// measurement is used as slice element for publishing structured data
+type measurement struct {
+	Power         float64   `json:"power"`
+	Energy        float64   `json:"energy,omitempty"`
+	Powers        []float64 `json:"powers,omitempty"`
+	Currents      []float64 `json:"currents,omitempty"`
+	ExcessDCPower float64   `json:"excessdcpower,omitempty"`
+	Capacity      *float64  `json:"capacity,omitempty"`
+	Soc           *float64  `json:"soc,omitempty"`
+	Controllable  *bool     `json:"controllable,omitempty"`
 }
 
 var _ site.API = (*Site)(nil)
@@ -434,25 +430,17 @@ func (site *Site) publishDelta(key string, val interface{}) {
 	site.publish(key, val)
 }
 
-// updatePvMeters updates pv meters. All measurements are optional.
-func (site *Site) updatePvMeters() {
-	if len(site.pvMeters) == 0 {
-		return
-	}
-
+func (site *Site) collectMeters(key string, meters []api.Meter) []measurement {
 	var wg sync.WaitGroup
-
-	mm := make([]meterMeasurement, len(site.pvMeters))
+	mm := make([]measurement, len(meters))
 
 	fun := func(i int, meter api.Meter) {
 		// power
 		power, err := backoff.RetryWithData(meter.CurrentPower, bo())
 		if err == nil {
-			if power < -500 {
-				site.log.WARN.Printf("pv %d power: %.0fW is negative - check configuration if sign is correct", i+1, power)
-			}
+			site.log.DEBUG.Printf("%s %d power: %.0fW", key, i+1, power)
 		} else {
-			site.log.ERROR.Printf("pv %d power: %v", i+1, err)
+			site.log.ERROR.Printf("%s %d power: %v", key, i+1, err)
 		}
 
 		// energy (production)
@@ -460,171 +448,95 @@ func (site *Site) updatePvMeters() {
 		if m, ok := meter.(api.MeterEnergy); err == nil && ok {
 			energy, err = m.TotalEnergy()
 			if err != nil {
-				site.log.ERROR.Printf("pv %d energy: %v", i+1, err)
-			}
-		}
-
-		var excessDC float64
-		var excessStr string
-		if m, ok := meter.(api.MaxACPower); ok {
-			if dc := m.MaxACPower() - power; dc < 0 && power > 0 {
-				excessDC = -dc
-				excessStr = fmt.Sprintf(" (includes %.0fW excess DC)", -dc)
+				site.log.ERROR.Printf("%s %d energy: %v", key, i+1, err)
 			}
 		}
 
-		if len(site.pvMeters) > 1 {
-			site.log.DEBUG.Printf("pv %d power: %.0fW"+excessStr, i+1, power)
-		}
-
-		mm[i] = meterMeasurement{
-			Power:         power,
-			Energy:        energy,
-			ExcessDCPower: excessDC,
+		mm[i] = measurement{
+			Power:  power,
+			Energy: energy,
 		}
 
 		wg.Done()
 	}
 
-	wg.Add(len(site.pvMeters))
-	for i, meter := range site.pvMeters {
+	wg.Add(len(meters))
+	for i, meter := range meters {
 		go fun(i, meter)
 	}
 	wg.Wait()
 
-	site.pvPower = lo.Reduce(mm, func(acc float64, m meterMeasurement, _ int) float64 {
-		return acc + max(0, m.Power)
-	}, 0)
-	site.excessDCPower = lo.Reduce(mm, func(acc float64, m meterMeasurement, _ int) float64 {
-		return acc - math.Abs(m.ExcessDCPower)
-	}, 0)
-	totalEnergy := lo.Reduce(mm, func(acc float64, m meterMeasurement, _ int) float64 {
-		return acc + m.Energy
-	}, 0)
-
-	var excessStr string
-	if site.excessDCPower < 0 {
-		excessStr = fmt.Sprintf(" (includes %.0fW excess DC)", -site.excessDCPower)
-	}
-
-	site.log.DEBUG.Printf("pv power: %.0fW"+excessStr, site.pvPower)
-	site.publish(keys.PvPower, site.pvPower)
-	site.publish(keys.PvEnergy, totalEnergy)
-	site.publish(keys.Pv, mm)
+	return mm
 }
 
-// updateAuxMeters updates aux meters
-func (site *Site) updateAuxMeters() {
-	if len(site.auxMeters) == 0 {
+// updatePvMeters updates pv meters. All measurements are optional.
+func (site *Site) updatePvMeters() {
+	if len(site.pvMeters) == 0 {
 		return
 	}
 
-	var wg sync.WaitGroup
+	mm := site.collectMeters("pv", site.pvMeters)
 
-	mm := make([]meterMeasurement, len(site.auxMeters))
+	for i, meter := range site.pvMeters {
+		power := mm[i].Power
 
-	fun := func(i int, meter api.Meter) {
-		if power, err := meter.CurrentPower(); err == nil {
-			mm[i].Power = power
-			site.log.DEBUG.Printf("aux power %d: %.0fW", i+1, power)
-		} else {
-			site.log.ERROR.Printf("aux meter %d: %v", i+1, err)
+		if power < -500 {
+			site.log.WARN.Printf("pv %d power: %.0fW is negative - check configuration if sign is correct", i+1, power)
 		}
 
-		wg.Done()
-	}
-
-	wg.Add(len(site.auxMeters))
-	for i, meter := range site.auxMeters {
-		go fun(i, meter)
-	}
-	wg.Wait()
-
-	site.auxPower = lo.Reduce(mm, func(acc float64, m meterMeasurement, _ int) float64 {
-		return acc + m.Power
-	}, 0)
-
-	site.log.DEBUG.Printf("aux power: %.0fW", site.auxPower)
-	site.publish(keys.AuxPower, site.auxPower)
-	site.publish(keys.Aux, mm)
-}
-
-// updateExtMeters updates ext meters
-func (site *Site) updateExtMeters() {
-	if len(site.extMeters) == 0 {
-		return
+		if m, ok := meter.(api.MaxACPower); ok {
+			if dc := m.MaxACPower() - power; dc < 0 && power > 0 {
+				mm[i].ExcessDCPower = -dc
+				site.log.DEBUG.Printf("pv %d excess DC: %.0fW", i+1, -dc)
+			}
+		}
 	}
 
-	mm := make([]meterMeasurement, len(site.extMeters))
-
-	for i, meter := range site.extMeters {
-		// ext power
-		power, err := backoff.RetryWithData(meter.CurrentPower, bo())
-		if err != nil {
-			site.log.ERROR.Printf("ext meter %d power: %v", i+1, err)
-		}
+	site.pvPower = lo.SumBy(mm, func(m measurement) float64 {
+		return max(0, m.Power)
+	})
+	site.excessDCPower = lo.SumBy(mm, func(m measurement) float64 {
+		return math.Abs(m.ExcessDCPower)
+	})
+	totalEnergy := lo.SumBy(mm, func(m measurement) float64 {
+		return m.Energy
+	})
 
-		// ext energy
-		var energy float64
-		if m, ok := meter.(api.MeterEnergy); err == nil && ok {
-			energy, err = m.TotalEnergy()
-			if err != nil {
-				site.log.ERROR.Printf("ext meter %d energy: %v", i+1, err)
-			}
+	if len(site.pvMeters) > 1 {
+		var excessStr string
+		if site.excessDCPower < 0 {
+			excessStr = fmt.Sprintf(" (includes %.0fW excess DC)", -site.excessDCPower)
 		}
 
-		mm[i] = meterMeasurement{
-			Power:  power,
-			Energy: energy,
-		}
+		site.log.DEBUG.Printf("pv power: %.0fW"+excessStr, site.pvPower)
 	}
 
-	// Publishing will be done in separate PR
+	site.publish(keys.PvPower, site.pvPower)
+	site.publish(keys.PvEnergy, totalEnergy)
+	site.publish(keys.Pv, mm)
 }
 
 // updateBatteryMeters updates battery meters
-func (site *Site) updateBatteryMeters() error {
+func (site *Site) updateBatteryMeters() {
 	if len(site.batteryMeters) == 0 {
-		return nil
+		return
 	}
 
-	var eg errgroup.Group
-
-	mm := make([]batteryMeasurement, len(site.batteryMeters))
-
-	fun := func(i int, meter api.Meter) error {
-		power, err := backoff.RetryWithData(meter.CurrentPower, bo())
-		if err != nil {
-			// power is required- return on error
-			return fmt.Errorf("battery %d power: %v", i+1, err)
-		}
-
-		if len(site.batteryMeters) > 1 {
-			site.log.DEBUG.Printf("battery %d power: %.0fW", i+1, power)
-		}
-
-		// battery energy (discharge)
-		var energy float64
-		if m, ok := meter.(api.MeterEnergy); ok {
-			energy, err = m.TotalEnergy()
-			if err != nil {
-				site.log.ERROR.Printf("battery %d energy: %v", i+1, err)
-			}
-		}
+	mm := site.collectMeters("battery", site.batteryMeters)
 
+	for i, meter := range site.batteryMeters {
 		// battery soc and capacity
 		var batSoc, capacity float64
-		if meter, ok := meter.(api.Battery); ok {
-			batSoc, err = soc.Guard(meter.Soc())
+		var err error
 
+		if m, ok := meter.(api.Battery); ok {
+			batSoc, err = soc.Guard(m.Soc())
 			if err == nil {
-				if m, ok := meter.(api.BatteryCapacity); ok {
+				if m, ok := m.(api.BatteryCapacity); ok {
 					capacity = m.Capacity()
 				}
 
-				if len(site.batteryMeters) > 1 {
-					site.log.DEBUG.Printf("battery %d soc: %.0f%%", i+1, batSoc)
-				}
+				site.log.DEBUG.Printf("battery %d soc: %.0f%%", i+1, batSoc)
 			} else {
 				site.log.ERROR.Printf("battery %d soc: %v", i+1, err)
 			}
@@ -632,36 +544,21 @@ func (site *Site) updateBatteryMeters() error {
 
 		_, controllable := meter.(api.BatteryController)
 
-		mm[i] = batteryMeasurement{
-			Power:        power,
-			Energy:       energy,
-			Soc:          batSoc,
-			Capacity:     capacity,
-			Controllable: controllable,
-		}
-
-		return nil
-	}
-
-	for i, meter := range site.batteryMeters {
-		eg.Go(func() error { return fun(i, meter) })
+		mm[i].Soc = lo.ToPtr(batSoc)
+		mm[i].Capacity = lo.ToPtr(capacity)
+		mm[i].Controllable = lo.ToPtr(controllable)
 	}
 
-	if err := eg.Wait(); err != nil {
-		return err
-	}
-
-	site.batterySoc = lo.Reduce(mm, func(acc float64, m batteryMeasurement, _ int) float64 {
+	site.batterySoc = lo.SumBy(mm, func(m measurement) float64 {
 		// weigh soc by capacity
-		weighedSoc := m.Soc
-		if m.Capacity > 0 {
-			weighedSoc *= m.Capacity
+		if *m.Capacity > 0 {
+			return *m.Soc * *m.Capacity
 		}
-		return acc + weighedSoc
-	}, 0)
-	totalCapacity := lo.Reduce(mm, func(acc float64, m batteryMeasurement, _ int) float64 {
-		return acc + m.Capacity
-	}, 0)
+		return *m.Soc
+	})
+	totalCapacity := lo.SumBy(mm, func(m measurement) float64 {
+		return *m.Capacity
+	})
 
 	// convert weighed socs to total soc
 	if totalCapacity == 0 {
@@ -669,23 +566,53 @@ func (site *Site) updateBatteryMeters() error {
 	}
 	site.batterySoc /= totalCapacity
 
-	site.log.DEBUG.Printf("battery soc: %.0f%%", math.Round(site.batterySoc))
+	site.batteryPower = lo.SumBy(mm, func(m measurement) float64 {
+		return m.Power
+	})
+	totalEnergy := lo.SumBy(mm, func(m measurement) float64 {
+		return m.Energy
+	})
+
+	if len(site.batteryMeters) > 1 {
+		site.log.DEBUG.Printf("battery power: %.0fW", site.batteryPower)
+		site.log.DEBUG.Printf("battery soc: %.0f%%", math.Round(site.batterySoc))
+	}
+
 	site.publish(keys.BatteryCapacity, totalCapacity)
 	site.publish(keys.BatterySoc, site.batterySoc)
 
-	site.batteryPower = lo.Reduce(mm, func(acc float64, m batteryMeasurement, _ int) float64 {
-		return acc + m.Power
-	}, 0)
-	totalEnergy := lo.Reduce(mm, func(acc float64, m batteryMeasurement, _ int) float64 {
-		return acc + m.Energy
-	}, 0)
-
-	site.log.DEBUG.Printf("battery power: %.0fW", site.batteryPower)
 	site.publish(keys.BatteryPower, site.batteryPower)
 	site.publish(keys.BatteryEnergy, totalEnergy)
 	site.publish(keys.Battery, mm)
+}
 
-	return nil
+// updateAuxMeters updates aux meters
+func (site *Site) updateAuxMeters() {
+	if len(site.auxMeters) == 0 {
+		return
+	}
+
+	mm := site.collectMeters("aux", site.auxMeters)
+	site.auxPower = lo.SumBy(mm, func(m measurement) float64 {
+		return m.Power
+	})
+
+	if len(site.auxMeters) > 1 {
+		site.log.DEBUG.Printf("aux power: %.0fW", site.auxPower)
+	}
+
+	site.publish(keys.AuxPower, site.auxPower)
+	site.publish(keys.Aux, mm)
+}
+
+// updateExtMeters updates ext meters
+func (site *Site) updateExtMeters() {
+	if len(site.extMeters) == 0 {
+		return
+	}
+
+	mm := site.collectMeters("ext", site.extMeters)
+	site.publish(keys.Ext, mm)
 }
 
 // updateGridMeter updates grid meter
@@ -694,10 +621,12 @@ func (site *Site) updateGridMeter() error {
 		return nil
 	}
 
+	var mm measurement
+
 	if res, err := backoff.RetryWithData(site.gridMeter.CurrentPower, bo()); err == nil {
+		mm.Power = res
 		site.gridPower = res
 		site.log.DEBUG.Printf("grid power: %.0fW", res)
-		site.publish(keys.GridPower, res)
 	} else {
 		return fmt.Errorf("grid power: %v", err)
 	}
@@ -709,18 +638,16 @@ func (site *Site) updateGridMeter() error {
 		if phaseMeter, ok := site.gridMeter.(api.PhasePowers); ok {
 			var err error // phases needed for signed currents
 			if p1, p2, p3, err = phaseMeter.Powers(); err == nil {
-				phases := []float64{p1, p2, p3}
-				site.log.DEBUG.Printf("grid powers: %.0fW", phases)
-				site.publish(keys.GridPowers, phases)
+				mm.Powers = []float64{p1, p2, p3}
+				site.log.DEBUG.Printf("grid powers: %.0fW", mm.Powers)
 			} else {
 				site.log.ERROR.Printf("grid powers: %v", err)
 			}
 		}
 
 		if i1, i2, i3, err := phaseMeter.Currents(); err == nil {
-			phases := []float64{util.SignFromPower(i1, p1), util.SignFromPower(i2, p2), util.SignFromPower(i3, p3)}
-			site.log.DEBUG.Printf("grid currents: %.3gA", phases)
-			site.publish(keys.GridCurrents, phases)
+			mm.Currents = []float64{util.SignFromPower(i1, p1), util.SignFromPower(i2, p2), util.SignFromPower(i3, p3)}
+			site.log.DEBUG.Printf("grid currents: %.3gA", mm.Currents)
 		} else {
 			site.log.ERROR.Printf("grid currents: %v", err)
 		}
@@ -729,12 +656,14 @@ func (site *Site) updateGridMeter() error {
 	// grid energy (import)
 	if energyMeter, ok := site.gridMeter.(api.MeterEnergy); ok {
 		if f, err := energyMeter.TotalEnergy(); err == nil {
-			site.publish(keys.GridEnergy, f)
+			mm.Energy = f
 		} else {
 			site.log.ERROR.Printf("grid energy: %v", err)
 		}
 	}
 
+	site.publish(keys.Grid, mm)
+
 	return nil
 }
 
@@ -742,10 +671,10 @@ func (site *Site) updateMeters() error {
 	var eg errgroup.Group
 
 	eg.Go(func() error { site.updatePvMeters(); return nil })
+	eg.Go(func() error { site.updateBatteryMeters(); return nil })
 	eg.Go(func() error { site.updateAuxMeters(); return nil })
 	eg.Go(func() error { site.updateExtMeters(); return nil })
 
-	eg.Go(site.updateBatteryMeters)
 	eg.Go(site.updateGridMeter)
 
 	return eg.Wait()
@@ -763,13 +692,13 @@ func (site *Site) sitePower(totalChargePower, flexiblePower float64) (float64, b
 	// allow using PV as estimate for grid power
 	if site.gridMeter == nil {
 		site.gridPower = totalChargePower - site.pvPower
-		site.publish(keys.GridPower, site.gridPower)
+		site.publish(keys.Grid, measurement{Power: site.gridPower})
 	}
 
 	// ensure safe default for residual power
 	residualPower := site.GetResidualPower()
 	if len(site.batteryMeters) > 0 && site.batterySoc < site.prioritySoc && residualPower <= 0 {
-		residualPower = 100 // W
+		residualPower = 100 // Wsite.publish(keys.PvPower,
 	}
 
 	// allow using grid and charge as estimate for pv power
@@ -997,8 +926,11 @@ func (site *Site) prepare() {
 	site.publish(keys.SiteTitle, site.Title)
 
 	site.publish(keys.GridConfigured, site.gridMeter != nil)
+	site.publish(keys.Grid, api.Meter(nil))
 	site.publish(keys.Pv, make([]api.Meter, len(site.pvMeters)))
 	site.publish(keys.Battery, make([]api.Meter, len(site.batteryMeters)))
+	site.publish(keys.Aux, make([]api.Meter, len(site.auxMeters)))
+	site.publish(keys.Ext, make([]api.Meter, len(site.extMeters)))
 	site.publish(keys.PrioritySoc, site.prioritySoc)
 	site.publish(keys.BufferSoc, site.bufferSoc)
 	site.publish(keys.BufferStartSoc, site.bufferStartSoc)
diff --git a/core/stats.go b/core/stats.go
index ea4a082e..d224e357 100644
--- a/core/stats.go
+++ b/core/stats.go
@@ -7,6 +7,7 @@ import (
 	"github.com/evcc-io/evcc/core/keys"
 	"github.com/evcc-io/evcc/server/db"
 	"github.com/evcc-io/evcc/util"
+	"github.com/jinzhu/now"
 )
 
 // publisher gives access to the site's publish function
@@ -33,10 +34,10 @@ func (s *Stats) Update(p publisher) {
 	}
 
 	stats := map[string]map[string]float64{
-		"30d":      s.calculate(30),
-		"365d":     s.calculate(365),
-		"thisYear": s.calculate(time.Now().YearDay()),
-		"total":    s.calculate(365 * 100), // 100 years
+		"30d":      s.calculate(time.Now().AddDate(0, 0, -30)),
+		"365d":     s.calculate(time.Now().AddDate(0, 0, -365)),
+		"thisYear": s.calculate(now.BeginningOfYear()),
+		"total":    s.calculate(time.Time{}),
 	}
 	p.publish(keys.Statistics, stats)
 
@@ -44,7 +45,7 @@ func (s *Stats) Update(p publisher) {
 }
 
 // calculate reads the stats for the last n-days
-func (s *Stats) calculate(days int) map[string]float64 {
+func (s *Stats) calculate(fromDate time.Time) map[string]float64 {
 	result := make(map[string]float64)
 
 	executeQuery := func(selectClause string, whereClause string, fromDate time.Time, dest interface{}) {
@@ -60,7 +61,6 @@ func (s *Stats) calculate(days int) map[string]float64 {
 		}
 	}
 
-	fromDate := time.Now().AddDate(0, 0, -days)
 	var solarPercentage, chargedKWh, avgPrice, avgCo2 float64
 	executeQuery("SUM(charged_kwh * solar_percentage) / SUM(charged_kwh)", "AND solar_percentage IS NOT NULL", fromDate, &solarPercentage)
 	executeQuery("SUM(charged_kwh)", "AND solar_percentage IS NOT NULL", fromDate, &chargedKWh)
diff --git a/evcc.dist.yaml b/evcc.dist.yaml
index 08bb7512..ef51b0f2 100644
--- a/evcc.dist.yaml
+++ b/evcc.dist.yaml
@@ -151,86 +151,19 @@ tariffs:
         price: 0.2 # EUR/kWh
       - days: Sat,Sun
         price: 0.15 # EUR/kWh
-
-    # or variable tariffs
-    # type: tibber
-    # token: "476c477d8a039529478ebd690d35ddd80e3308ffc49b59c65b142321aee963a4" # access token
-    # homeid: "cc83e83e-8cbf-4595-9bf7-c3cf192f7d9c" # optional if multiple homes associated to account
-
-    # type: awattar
-    # region: de # optional, choose at for Austria
-    # charges: # optional, additional charges per kWh
-    # tax: # optional, additional tax (0.1 for 10%)
-
-    # type: octopusenergy
-    # tariff: AGILE-FLEX-22-11-25 # Tariff code
-    # region: A # optional
-
-    # type: elering # Nordpool
-    # region: ee # or lt, lv, fi
-    # charges: # optional, additional charges per kWh
-    # tax: # optional, additional tax (0.1 for 10%)
-
-    # type: energinet # Energinet using the price in DKK
-    # region: dk1 # or dk2
-    # charges: # optional, additional charges per kWh
-    # tax: # optional, additional tax (0.1 for 10%)
-
-    # type: entsoe # Entso-E european market data
-    # domain: BZN|DE-LU # https://transparency.entsoe.eu/content/static_content/Static%20content/web%20api/Guide.html#_areas
-    # securitytoken: # api token
-    # charges: # optional, additional charges per kWh
-    # tax: # optional, additional tax (0.1 for 10%)
-
-    # type: pun # PUN - Prezzo unico nazionale - Hourly Italian wholesale prices
-    # charges: 0 # optional, additional charges per kWh
-    # tax: 0 # optional, additional tax (0.1 for 10%)
-
-    # type: amber
-    # token: # api token from https://app.amber.com.au/developers/
-    # siteid: # site ID returned by the API
-    # channel: general
-
-    # type: custom # price from a plugin source; see https://docs.evcc.io/docs/reference/plugins
-    # price:
-    #   source: http
-    #   uri: https://example.org/price.json
-    #   jq: .price.current
-
-    # type: template
-    # template: energy-charts-api # epex spot market prices
-    # bzn: DE-LU
-    # charges: 0.15
-    # tax: 0.1
-    # formula: math.Min((price + charges) * (1 + tax), 0.5)
+    # see: https://docs.evcc.io/en/docs/devices/tariffs
 
   feedin:
     # rate for feeding excess (pv) energy to the grid
     type: fixed
     price: 0.08 # EUR/kWh
-
-    # type: octopusenergy
-    # tariff: AGILE-FLEX-22-11-25 # Tariff code
-    # region: A # optional
-
-    # type: amber
-    # token: # api token from https://app.amber.com.au/developers/
-    # siteid: # site ID returned by the API
-    # channel: feedIn
+    # see: https://docs.evcc.io/en/docs/devices/tariffs
   co2:
     # co2 tariff provides co2 intensity forecast and is for co2-optimized target charging if no variable grid tariff is specified
-    # type: grünstromindex # GrünStromIndex (Germany only)
+    # type: template
+    # template: grünstromindex # GrünStromIndex (Germany only)
     # zip: <zip>
-
-    # type: electricitymaps # https://app.electricitymaps.com/map
-    # uri: <uri>
-    # token: <token> # needs to be a token with forecast (not in the free tier)
-    # zone: DE
-
-    # type: ngeso # National Grid Electricity System Operator data (Great Britain only) https://carbonintensity.org.uk/
-    # provides national data if both region and postcode are omitted - Choose ONE only!
-    # region: 1 # optional, coarser than using a postcode - The region details are at https://carbon-intensity.github.io/api-definitions/#region-list
-    # postcode: SW1 # optional - Outward postcode i.e. RG41 or SW1 or TF8. Do not include full postcode, outward postcode only
+    # see: https://docs.evcc.io/en/docs/devices/tariffs#co-forecast
 
 # mqtt message broker
 mqtt:
diff --git a/i18n/bg.toml b/i18n/bg.toml
index 8f517861..cd673510 100644
--- a/i18n/bg.toml
+++ b/i18n/bg.toml
@@ -1,20 +1,25 @@
 [batterySettings]
 batteryLevel = "Батерия %"
 capacity = "{energy} от {total}"
+control = "Управление на батерията"
+discharge = "Предотврати разреждането в бърз режим и планираното зареждане"
 disclaimerHint = "Бележка:"
 disclaimerText = "Тези настройки важат само за режим на работа използвайки енергия от фотоволтаици. Зареждането ще бъде настроено спрямо тях"
-legendBottomName = "приоритизиране на дом"
-legendBottomSubline = "не се използва за зареждане"
-legendMiddleName = "приоритизиране на кола"
-legendMiddleSubline = "дом на заден план"
+gridChargeTab = "Зареждане от мрежата"
+legendBottomName = "Приоритизиране на зареждането на домашната батерия"
+legendBottomSubline = "докато достигне {soc}."
+legendMiddleName = "Приоритизиране на зареждането на превозното средство"
+legendMiddleSubline = "когато домашната батерия е над {soc}."
 legendTitle = "Как да се използва енергията от фотоволтаиците?"
 legendTopAutostart = "автоматично стартиране"
 legendTopName = "зареждане използвайки батерия"
-legendTopSubline = "без прекъсвания"
-modalTitle = "Настройки на батерия"
+legendTopSubline = "когато домашната батерия е над {soc}."
+modalTitle = "Домашна батерия"
+usageTab = "Използване на батерията"
 
 [batterySettings.bufferStart]
-full = "при почти заредена батерия"
+above = "когато над {soc}"
+full = "при {soc}."
 low = "при донякъде заредена батерия"
 medium = "при наполовина заредена батерия"
 never = "само при наличие на излишък"
@@ -22,62 +27,213 @@ never = "само при наличие на излишък"
 [config]
 
 [config.battery]
+titleAdd = "Добави батерия"
+titleEdit = "Редактиране на батерията"
 
 [config.circuits]
+description = "Гарантира, че сумата от всички точки на натоварване, свързани към една верига, не надвишава конфигурираните граници на мощност и ток. Веригите могат да бъдат вложени, за да се изгради йерархия."
+title = "Управление на натоварването"
 
 [config.control]
+description = "Обикновено стойностите по подразбиране са наред. Променяйте ги само ако знаете какво правите."
+descriptionInterval = "Цикъл на актуализация на контролния цикъл в секунди. Определя колко често evcc чете данни от измервателния уред, регулира мощността на зареждане и актуализира потребителския интерфейс. Кратките интервали (< 30s) могат да причинят осцилации и нежелано поведение."
+descriptionResidualPower = "Премества работната точка на контролния цикъл. Ако имате домашна батерия, се препоръчва да зададете стойност от 100 W. По този начин батерията ще има лек приоритет пред използването на мрежата."
+labelInterval = "Интервал на актуализация"
+labelResidualPower = "Остатъчна мощност"
+title = "Поведение на управление"
 
 [config.deviceValue]
+broker = "Брокер"
+bucket = "Кофа"
+capacity = "Капацитет"
+chargeStatus = "Статус"
+chargeStatusA = "не е свързан"
+chargeStatusB = "свързан"
+chargeStatusC = "зареждане"
+chargeStatusE = "няма мощност"
+chargeStatusF = "грешка"
+chargedEnergy = "Заредено"
+co2 = "Мрежа CO₂"
+configured = "Конфигуриран"
+controllable = "Контролируем"
+currency = "Валута"
+current = "Електричеството"
+currentRange = "Електричеството"
+enabled = "Готов за зареждане"
+energy = "Енергия"
+feedinPrice = "Цена за изкупуване"
+gridPrice = "Цена на мрежата"
+hemsType = "Система"
+no = "не"
+odometer = "одометър"
+org = "Организация"
+phaseCurrents = "Текущ L1, L2, L3"
+phasePowers = "Мощност L1, L2, L3"
+phaseVoltages = "Напрежение L1, L2, L3"
+power = "Мощност"
+powerRange = "Мощност"
+range = "Обхват"
+soc = "Състояние на заряда"
+socLimit = "Лимит"
+temp = "Температура"
+topic = "Тема"
+url = "URL"
+yes = "да"
 
 [config.eebus]
+description = "Основна конфигурация за комуникация с други EEBus устройства."
+title = "EEBus"
 
 [config.form]
 example = "Пример"
 optional = "незадължителен"
 
 [config.general]
+cancel = "Отказ"
+docsLink = "Виж документацията."
+experimental = "Експериментален"
+hideAdvancedSettings = "Скрий разширените настройки"
+off = "изключено"
+on = "включено"
+password = "Парола"
+readFromFile = "Прочети от файл"
+remove = "Премахни"
+save = "Запази"
+showAdvancedSettings = "Покажи разширените настройки"
+telemetry = "Телеметрия"
+title = "Заглавие"
 
 [config.grid]
+title = "Мрежов измервателен уред"
+titleAdd = "Добави мрежов измервателен уред"
+titleEdit = "Редактирай мрежов измервателен уред"
 
 [config.hems]
+description = "Свържете evcc с друга система за управление на домашната енергия."
+title = "„Система за управление на енергията в дома“"
 
 [config.influx]
+description = "Записва данни за зареждане и други метрики в InfluxDB. Използвайте Grafana или други инструменти за визуализиране на данните."
+descriptionToken = "Проверете документацията на InfluxDB, за да научите как да създадете такъв. https://docs.influxdata.com/influxdb/v2/admin/"
+labelBucket = "Кофа"
+labelCheckInsecure = "Разреши самоподписани сертификати"
+labelDatabase = "База данни"
+labelInsecure = "„Проверка на сертификат“"
+labelOrg = "Организация"
+labelPassword = "Парола"
+labelToken = "API токен"
+labelUrl = "URL"
+labelUser = "Потребителско име"
+title = "InfluxDB"
+v1Support = "Нуждаете се от поддръжка за InfluxDB 1.x?"
+v2Support = "Назад към InfluxDB 2.x"
 
 [config.main]
+addLoadpoint = "Добави зарядна точка"
+addPvBattery = "Добави соларен панел или батерия"
 addVehicle = "Добави автомобил"
+configured = "Конфигуриран"
 edit = "редактирай"
 title = "Конфигурация"
+unconfigured = "не е конфигуриран"
 vehicles = "Моите Автомобили"
+yaml = "Конфигурирано в evcc.yaml. Не може да се редактира в потребителския интерфейс."
 
 [config.messaging]
+description = "Получавайте съобщения за вашите сесии на зареждане."
+title = "Notifications"
 
 [config.meter]
+cancel = "Отказ"
+delete = "Изтрий"
+save = "Запази"
+template = "Производител"
+titleChoice = "Какво искате да добавите?"
+validateSave = "Валидирай и запази"
 
 [config.modbusproxy]
+description = "Позволете на няколко клиента да имат достъп до едно Modbus устройство."
+title = "„Модбус прокси“"
 
 [config.mqtt]
+authentication = "Автентикация"
+description = "Свържете се с MQTT брокер, за да обменяте данни с други системи във вашата мрежа."
+descriptionClientId = "Автор на съобщенията. Ако е празно, се използва `evcc-[rand]`."
+descriptionTopic = "Оставете празно, за да деактивирате публикуването."
+labelBroker = "Брокер"
+labelCaCert = "Сървърен сертификат (CA)"
+labelCheckInsecure = "Позволете самоподписани сертификати"
+labelClientCert = "Клиентски сертификат"
+labelClientId = "Клиентски идентификатор"
+labelClientKey = "Клиентски ключ"
+labelInsecure = "Валидиране на сертификат"
+labelPassword = "Парола"
+labelTopic = "Тема"
+labelUser = "Потребителско име"
+publishing = "Публикуване"
+title = "MQTT"
 
 [config.network]
+descriptionHost = "„Използвайте разширението .local, за да активирате mDNS. Това е от значение за разпознаване на мобилни приложения и някои зарядни станции OCPP.“"
+descriptionPort = "Порт за уеб интерфейса и API. Ще трябва да актуализирате URL адреса на браузъра си, ако промените това."
+descriptionSchema = "Влияе само на начина, по който се генерират URL адресите. Изборът на HTTPS няма да активира криптиране."
+labelHost = "Хост име"
+labelPort = "Порт"
+labelSchema = "Схема"
+title = "Мрежа"
 
 [config.options]
 
 [config.options.boolean]
+no = "не"
+yes = "да"
 
 [config.options.endianness]
+big = "голям ендиан"
+little = "„малко ендианско“"
 
 [config.options.schema]
+http = "HTTP (некриптиран)"
+https = "HTTPS (криптиран)"
 
 [config.pv]
+titleAdd = "Добавяне на соларен метър"
+titleEdit = "Редактиране на соларен метър"
 
 [config.section]
+general = "Общи"
+grid = "Мрежова връзка"
+integrations = "Интеграции"
+loadpoints = "Зарядни точки"
+meter = "Солар и батерия"
+system = "Система"
+vehicles = "Превозни средства"
 
 [config.sponsor]
+addToken = "Въведете спонсорски токен"
+changeToken = "Промяна на спонсорския токен"
+description = "Моделът за спонсорство ни помага да поддържаме проекта и устойчиво да изграждаме нови и вълнуващи функции. Като спонсор получавате достъп до всички реализации на зарядни устройства."
+descriptionToken = "Получавате токена от {url}. Ние също предлагаме пробен токен за тестване."
+error = "Спонсорският токен не е валиден."
+labelToken = "Спонсорски токен"
+title = "Спонсорство"
 
 [config.system]
+logs = "Логове"
+restart = "Рестартиране"
+restartRequiredDescription = "Моля, рестартирайте, за да видите ефекта."
+restartRequiredMessage = "Конфигурацията е променена."
+restartingDescription = "Моля, изчакайте..."
+restartingMessage = "Рестартиране на evcc."
 
 [config.tariffs]
+description = "Определете вашите енергийни тарифи, за да изчислите разходите за вашите сесии на зареждане."
+title = "Тарифи"
 
 [config.title]
+description = "Показва се на главния екран и в раздела на браузъра."
+label = "Заглавие"
+title = "Редактиране на заглавие"
 
 [config.validation]
 failed = "неуспешен"
@@ -115,15 +271,22 @@ powerSub2 = "зарежда..."
 tabTitle = "Общност"
 
 [footer.savings]
+co2Saved = "{value} запазени"
+co2Title = "CO₂ емисии"
+configurePriceCo2 = "Научете как да конфигурирате данните за цените и CO₂."
 footerLong = "{percent} енергия от слънцето"
 footerShort = "{percent} слънчева енергия"
 modalTitle = "Информация за зареждането"
+moneySaved = "{value} запазени"
 percentGrid = "{grid} кВч от мрежата"
 percentSelf = "{self} кВч от фотоволтаици"
 percentTitle = "Енергия от фотоволтаици"
+periodLabel = "Период:"
 priceFeedIn = "{feedInPrice} feed-in"
 priceGrid = "{gridPrice} мрежа"
 priceTitle = "Цена"
+referenceGrid = "Мрежа"
+referenceLabel = "Референтни данни:"
 savingsComparedToGrid = "сравнено с мрежата"
 savingsTitle = "Спестявате"
 savingsTotalEnergy = "{total} kWh заредени"
@@ -131,9 +294,14 @@ since = "от {since}"
 tabTitle = "Моите данни"
 
 [footer.savings.period]
+30d = "последните 30 дни"
+365d = "последните 365 дни"
+thisYear = "тази година"
+total = "всички времена"
 
 [footer.sponsor]
 becomeSponsor = "Станете Спонсор"
+becomeSponsorExtended = "Подкрепете ни директно, за да получите стикери."
 confetti = "Готови ли сте за конфети?"
 confettiPromise = "Получавате стикери и дигитални конфети"
 sticker = "... или evcc стикери?"
@@ -141,6 +309,10 @@ supportUs = "Нашата мисия е да направим слънчеват
 thanks = "Благодарим Ви, {sponsor}! С ваша помощ продължаваме да развиваме evcc."
 titleNoSponsor = "Подкрепете ни"
 titleSponsor = "Вие сте спонсор"
+titleTrial = "Режим на проба"
+titleVictron = "Спонсорирано от Victron Energy"
+trial = "Вие сте в пробен режим и можете да използвате всички функции. Моля, обмислете възможността да подкрепите проекта."
+victron = "Вие използвате evcc на хардуера на Victron Energy и имате достъп до всички функции."
 
 [footer.telemetry]
 optIn = "Искам да споделям моите данни."
@@ -161,96 +333,438 @@ modalUpdateStarted = "Стартирай новата версия на evcc..."
 modalUpdateStatusStart = "Инсталацията започна:"
 
 [header]
+about = "Относно"
+blog = "Блог"
+docs = "Документация"
+github = "GitHub"
+login = "Автомобил-влизания в системата"
+logout = "Изход"
+nativeSettings = "Промяна на сървъра"
+needHelp = "Нуждаете се от помощ?"
+sessions = "Сесии за зареждане"
 
 [help]
+discussionsButton = "GitHub дискусии"
+documentationButton = "Документация"
+issueButton = "Докладвайте за грешка"
+issueDescription = "Открихте ли странно или неправилно поведение?"
+logsButton = "Преглед на дневниците"
+logsDescription = "Проверете дневниците за грешки."
+modalTitle = "Нуждаете се от помощ?"
+primaryActions = "Нещо не работи както трябва? Това са добри места, където можете да получите помощ."
+restartButton = "Рестартиране"
+restartDescription = "Опитахте ли да го изключите и включите отново?"
+secondaryActions = "Все още не можете да решите проблема си? Ето някои по-сериозни опции."
 
 [help.restart]
+cancel = "Отказ"
+confirm = "Да, рестартирайте!"
+description = "При нормални обстоятелства рестартирането не би трябвало да е необходимо. Моля, обмислете подаването на сигнал за грешка, ако трябва редовно да рестартирате evcc."
+disclaimer = "Забележка: evcc ще се прекрати и ще разчита на операционната система за рестартиране на услугата."
+modalTitle = "Сигурни ли сте, че искате да рестартирате?"
 
 [log]
+areaLabel = "Филтриране по област"
+areas = "Всички области"
+download = "Изтеглете пълния дневник"
+levelLabel = "Филтриране по ниво на дневника"
+nAreas = "{count} области"
+noResults = "Няма съвпадащи записи в дневника."
+search = "Търсене"
+selectAll = "Изберете всички"
+showAll = "Показване на всички записи"
+title = "Дневници"
+update = "Автоматично обновяване"
 
 [loginModal]
+cancel = "Отказ"
+error = "Входът не бе успешен: "
+iframeHint = "Отворете evcc в нов раздел."
+iframeIssue = "Вашата парола е правилна, но изглежда, че браузърът ви е загубил бисквитката за удостоверяване. Това може да се случи, ако стартирате evcc в iframe чрез HTTP."
+invalid = "Паролата е невалидна."
+login = "Вход"
+password = "Парола"
+reset = "Нулиране на паролата?"
+title = "Удостоверяване"
 
 [main]
+vehicles = "Паркиране"
 
 [main.chargingPlan]
+active = "Активен"
+addRepeatingPlan = "Добавяне на повтарящ се план"
+arrivalTab = "Пристигане"
+day = "Ден"
+departureTab = "Заминаване"
+goal = "Цел на зареждане"
+modalTitle = "План за зареждане"
+none = "няма"
+planNumber = "План {number}"
+remove = "Премахване"
+repeating = "повтарящ се"
+repeatingPlans = "Повтарящи се планове"
+selectAll = "Изберете всички"
+time = "Време"
+title = "План"
+titleMinSoc = "Минимално зареждане"
+titleTargetCharge = "Заминаване"
+unsavedChanges = "Има незаписани промени. Приложи сега?"
+update = "Приложи"
+weekdays = "Дни"
 
 [main.energyflow]
+battery = "Батерия"
+batteryCharge = "Зареждане на батерията"
+batteryDischarge = "Разреждане на батерията"
+batteryHold = "Батерия (заключена)"
+batteryTooltip = "{energy} от {total} ({soc})"
+gridImport = "Използвана енергия от мрежата"
+homePower = "Потребление"
+loadpoints = "Зарядно устройство | Зарядно устройство | {count} зарядни устройства"
+noEnergy = "Няма данни от измервателния уред"
+pvExport = "Енергия, която се подава в електрическата мрежа"
+pvProduction = "Производство"
+selfConsumption = "Самопотребление"
 
 [main.heatingStatus]
+charging = "Загряване…"
+waitForVehicle = "Готово. Чака се нагревателят …"
 
 [main.loadpoint]
+avgPrice = "⌀ Цена"
+charged = "Заредено"
+co2 = "⌀ CO₂"
+duration = "Продължителност на зареждането"
+fallbackName = "Зарядна точка"
+power = "Мощност"
+price = "Цена"
+remaining = "Оставащо време"
+remoteDisabledHard = "{source}: изключено"
+remoteDisabledSoft = "{source}: изключено адаптивното соларно зареждане"
+solar = "Соларен"
 
 [main.loadpointSettings]
+batteryUsage = "Домашна батерия"
+currents = "Заряден ток"
+default = "по подразбиране"
+disclaimerHint = "Забележка:"
+onlyForSocBasedCharging = "Тези опции са налични само за превозни средства с известен заряд."
+smartCostCheap = "Евтино зареждане от мрежата"
+smartCostClean = "Чисто зареждане от мрежата"
+title = "Настройки {0}"
+vehicle = "Превозно средство"
 
 [main.loadpointSettings.batteryBoost]
+description = "Бързо зареждане от домашната батерия"
+label = "Батериен бууст"
+mode = "Налично само в соларен и мин+соларен режим"
+once = "Буустът е активен за тази сесия на зареждане"
 
 [main.loadpointSettings.limitSoc]
+description = "Лимит на зареждане, който се използва, когато това превозно средство е свързано"
+label = "Лимит по подразбиране"
 
 [main.loadpointSettings.maxCurrent]
+label = "Макс. ток"
 
 [main.loadpointSettings.minCurrent]
+label = "Мин. ток"
 
 [main.loadpointSettings.minSoc]
+description = "Превозното средство се зарежда „бързо“ до {0} в соларен режим. След това продължава със соларен излишък. Полезно за осигуряване на минимален пробег дори в по-тъмни дни."
+label = "Мин. заряд %"
 
 [main.loadpointSettings.phasesConfigured]
+label = "Фази"
+no1p3pSupport = "Как е свързана вашата зарядна станция?"
+phases_0 = "Автоматично превключване"
+phases_1 = "1 фаза"
+phases_1_hint = "({min} до {max})"
+phases_3 = "3 фази"
+phases_3_hint = "({min} до {max})"
 
 [main.mode]
+minpv = "Мин+Солар"
+now = "Бързо"
+off = "Изкл."
+pv = "Солар"
+smart = "Смарт"
 
 [main.provider]
+login = "Вход"
+logout = "Изход"
 
 [main.targetCharge]
+activate = "Активиране"
+co2Limit = "Лимит на CO₂ от {co2}"
+costLimitIgnore = "Конфигурираният {limit} ще бъде игнориран през този период."
+currentPlan = "Активен план"
+descriptionEnergy = "До кога трябва да бъде заредена {targetEnergy} в превозното средство?"
+descriptionSoc = "Кога трябва да бъде заредено превозното средство до {targetSoc}?"
+inactiveLabel = "Целево време"
+nextPlan = "Следващ план"
+notReachableInTime = "Целта ще бъде постигната {overrun} по-късно."
+onlyInPvMode = "Планът за зареждане работи само в соларен режим."
+planDuration = "Време за зареждане"
+planPeriodLabel = "Период"
+planPeriodValue = "{start} до {end}"
+planUnknown = "Все още не е известно"
+preview = "Преглед на плана"
+priceLimit = "Лимит на цената от {price}"
+remove = "Премахване"
+setTargetTime = "няма"
+targetIsAboveLimit = "Конфигурираният лимит за зареждане от {limit} ще бъде игнориран през този период."
+targetIsAboveVehicleLimit = "Лимитът на превозното средство е под целта за зареждане."
+targetIsInThePast = "Изберете време в бъдещето, Марти."
+targetIsTooFarInTheFuture = "Ще коригираме плана веднага щом научим повече за бъдещето."
+title = "Целево време"
+today = "днес"
+tomorrow = "утре"
+update = "Обновяване"
+vehicleCapacityDocs = "Научете как да го конфигурирате."
+vehicleCapacityRequired = "Капацитетът на батерията на превозното средство е необходим за изчисляване на времето за зареждане."
 
 [main.targetChargePlan]
+chargeDuration = "Време за зареждане"
+co2Label = "Емисия на CO₂ ⌀"
+priceLabel = "Цена на енергията"
+timeRange = "{day} {range} ч"
+unknownPrice = "Все още не е известно"
 
 [main.targetEnergy]
+label = "Лимит"
+noLimit = "няма"
 
 [main.vehicle]
+addVehicle = "Добавяне на превозно средство"
+changeVehicle = "Промяна на превозното средство"
+detectionActive = "Откриване на превозно средство…"
+fallbackName = "Превозно средство"
+moreActions = "Още действия"
+none = "Няма превозно средство"
+notReachable = "Превозното средство не беше достъпно. Опитайте да рестартирате evcc."
+targetSoc = "Лимит"
+temp = "Температура"
+tempLimit = "Целева температура"
+unknown = "Гостуващо превозно средство"
+vehicleSoc = "Зареждане"
 
 [main.vehicleSoc]
+charging = "зареждане"
+connected = "свързан"
+disconnected = "разединен"
+ready = "готов"
+vehicleLimit = "Лимит на превозното средство: {soc}"
 
 [main.vehicleStatus]
+awaitingAuthorization = "Изчакване на разрешение."
+batteryBoost = "Активно ускоряване на батерията."
+charging = "Зареждане…"
+cheapEnergyCharging = "Налична е евтина енергия."
+cheapEnergyNextStart = "Евтината енергия в {duration}"
+cheapEnergySet = "Лимитът на цената е зададен."
+cleanEnergyCharging = "Налична е чиста енергия."
+cleanEnergyNextStart = "Чиста енергия в {продължителност}."
+cleanEnergySet = "Лимитът на CO₂ е зададен."
+climating = "Открито е предварително кондициониране."
+connected = "Превозното средство е свързано"
+disconnectRequired = "Процесът е прекратен. Моля, свържете се отново."
+disconnected = "Разединен."
+finished = "Завършен."
+minCharge = "Минимално зареждане до {soc}."
+pvDisable = "Недостатъчен излишък. Скоро ще бъде пауза."
+pvEnable = "Наличен излишък. Скоро ще започне."
+scale1p = "Скоро ще се намали до еднофазно зареждане."
+scale3p = "Скоро ще се увеличи до трифазно зареждане."
+targetChargeActive = "Планът за зареждане е активен. Очаквано завършване след {duration}."
+targetChargePlanned = "Планът за таксуване започва в {duration}"
+targetChargeWaitForVehicle = "Планът за зареждане е готов. Очаква се превозното средство…"
+unknown = ""
+vehicleLimit = "Лимит на превозното средство."
+vehicleLimitReached = "Достигнат е лимитът на превозното средство."
+waitForVehicle = "Готов. Очакване на превозното средство…"
+welcome = "Кратко първоначално зареждане за потвърждаване на връзката."
 
 [notifications]
+dismissAll = "Отхвърли всички"
+logs = "Вижте пълните дневници"
+modalTitle = "Известия"
 
 [offline]
+configurationError = "Грешка при стартиране. Проверете конфигурацията си и рестартирайте."
+message = "Няма връзка със сървъра."
+restart = "Рестартиране"
+restartNeeded = "Необходимо за прилагане на промените."
+restarting = "Сървърът ще бъде отново на линия след малко."
 
 [passwordModal]
+description = "Задайте парола за защита на настройките на конфигурацията. Използването на основния екран е възможно и без влизане в системата."
+empty = "Паролата не трябва да бъде празна."
+error = "Грешка: "
+labelCurrent = "Текуща парола"
+labelNew = "Нова парола"
+labelRepeat = "Повторете паролата"
+newPassword = "Създайте парола"
+noMatch = "Паролите не съвпадат."
+titleNew = "Задайте парола на администратора"
+titleUpdate = "Обновяване на паролата на администратора"
+updatePassword = "Обновяване на паролата"
 
 [session]
+cancel = "Отказ"
+co2 = "CO₂"
+date = "Период"
+delete = "Изтрий"
+finished = "Завършено"
+meter = "Километраж"
+meterstart = "Начало на брояча"
+meterstop = "Край на брояча"
+odometer = "Пробег"
+price = "Цена"
+started = "Начално време"
+title = "Сесия на зареждане"
 
 [sessions]
+avgPower = "⌀ Мощност"
+avgPrice = "⌀ Цена"
+chargeDuration = "Продължителност"
+co2 = "⌀ CO₂"
+csvMonth = "Изтегляне на {month} CSV"
+csvTotal = "Изтегляне на общ CSV"
+date = "Начало"
+downloadCsv = "Изтегляне като CSV"
+energy = "Заредено"
+loadpoint = "Ладеен пункт"
+noData = "Няма сесии на зареждане този месец."
+overview = "Общ преглед"
+price = "Цена"
+reallyDelete = "Наистина ли искате да изтриете тази сесия?"
+showIndividualEntries = "Показване на отделни сесии"
+solar = "Слънчева енергия"
+title = "Сесии на зареждане"
+total = "Общо"
+vehicle = "Превозно средство"
 
 [sessions.chartTitle]
+avgCo2ByGroup = "⌀ CO₂ {byGroup}"
+avgPriceByGroup = "⌀ Цена {byGroup}"
+byGroupLoadpoint = "По зарядна точка"
+byGroupVehicle = "По превозно средство"
+energy = "Заредена енергия"
+energyGrouped = "Слънчева енергия срещу мрежова енергия"
+energyGroupedByGroup = "Енергия {byGroup}"
+energySubSolar = "{value} слънчева енергия"
+energySubTotal = "{value} общо"
+groupedCo2ByGroup = "Количество CO₂ {byGroup}"
+groupedPriceByGroup = "Обща цена {byGroup}"
+historyCo2 = "CO₂-емисии"
+historyCo2Sub = "{value} общо"
+historyPrice = "Разходи за зареждане"
+historyPriceSub = "{value} общо"
+solar = "Делът на слънчевата енергия в електроенергията през годината"
+solarByGroup = "Слънчев дял {byGroup}"
 
 [sessions.csv]
+chargedenergy = "Енергия (kWh)"
+chargeduration = "Продължителност"
+co2perkwh = "CO₂/kWh"
+created = "Начално време"
+finished = "Завършено"
+identifier = "Идентификатор"
+loadpoint = "Зарядна точка"
+meterstart = "Начално показание на електромера (kWh)"
+meterstop = "Крайно показание на електромера (kWh)"
+odometer = "Пробег (км)"
+price = "Цена"
+priceperkwh = "Цена/kWh"
+solarpercentage = "Слънчева енергия (%)"
+vehicle = "Превозно средство"
 
 [sessions.filter]
+allLoadpoints = "Всички зарядни точки"
+allVehicles = "Всички превозни средства"
+filter = "Филтър"
 
 [sessions.group]
+co2 = "Емисии"
+grid = "Мрежа"
+price = "Цена"
+self = "Слънчева енергия"
 
 [sessions.groupBy]
+loadpoint = "Зарядна точка"
+none = "Общо"
+vehicle = "Превозно средство"
 
 [sessions.period]
+month = "Месец"
+total = "Общо"
+year = "Година"
 
 [sessions.type]
+co2 = "CO₂"
+price = "Цена"
+solar = "Слънчева енергия"
 
 [settings]
+title = "Потребителски интерфейс"
 
 [settings.fullscreen]
+enter = "Въведете в режим на цял екран"
+exit = "Изход от режим на цял екран"
+label = "Цял екран"
 
 [settings.hiddenFeatures]
+label = "Експериментален"
+value = "Показване на експериментални функции на потребителския интерфейс."
 
 [settings.language]
+auto = "Автоматичен"
+label = "Език"
 
 [settings.sponsorToken]
+expires = "Вашият спонсорски токен изтича след {inXDays}. {getNewToken} и го актуализирайте тук."
+getNew = "Вземете нов"
+hint = "Забележка: В бъдеще ще автоматизираме това."
 
 [settings.telemetry]
+label = "Телеметрия"
 
 [settings.theme]
+auto = "Система"
+dark = "Тъмен"
+label = "Дизайн"
+light = "Светъл"
 
 [settings.unit]
+km = "km"
+label = "Единици"
+mi = "Мили"
 
 [smartCost]
 activeHours = "{charging} от {total}"
+activeHoursLabel = "Активни часове"
+applyToAll = "Приложи навсякъде?"
+batteryDescription = "Зарежда домашната батерия с енергия от мрежата."
+cheapTitle = "Евтино зареждане от мрежата"
+cleanTitle = "Чисто зареждане от мрежата"
+co2Label = "Емисия на CO₂"
+co2Limit = "Граница на CO₂"
+loadpointDescription = "Позволява временно бързо зареждане в соларен режим."
+modalTitle = "Интелигентно зареждане от мрежата"
+none = "Няма"
+priceLabel = "Цена на енергията"
+priceLimit = "Граница на цената"
+saved = "Запазено."
 
 [startupError]
+configFile = "Използван конфигурационен файл:"
+configuration = "Конфигурация"
+description = "Моля, проверете конфигурационния си файл. Ако съобщението за грешка не помогне, проверете {0}."
+discussions = "Дискусии в GitHub"
+fixAndRestart = "Моля, отстранете проблема и рестартирайте сървъра."
+hint = "Забележка: Възможно е също така да имате дефектно устройство (инвертор, измервателен уред и т.н.). Проверете мрежовите си връзки."
+lineError = "Грешка в {0}."
+lineErrorLink = "Ред {0}"
+restartButton = "Рестартиране"
+title = "Грешка при стартиране"
diff --git a/i18n/da.toml b/i18n/da.toml
index 94ca9673..e057d38d 100644
--- a/i18n/da.toml
+++ b/i18n/da.toml
@@ -423,8 +423,6 @@ batteryCharge = "Batteriet oplades"
 batteryDischarge = "Batteriet aflades"
 batteryHold = "Batteri (låst)"
 batteryTooltip = "{energi} af {total} ({soc})"
-cheapBatteryGridCharge = "billig strøm på elnettet"
-cleanBatteryGridCharge = "Grøn strøm på elnettet"
 gridImport = "Forbrug fra elnet"
 homePower = "Forbrug"
 loadpoints = "Oplader| Oplader | {count} opladere"
diff --git a/i18n/de.toml b/i18n/de.toml
index 4e45a865..ab44b7bf 100644
--- a/i18n/de.toml
+++ b/i18n/de.toml
@@ -408,10 +408,10 @@ weekdays = "Tage"
 battery = "Batterie"
 batteryCharge = "Batterie laden"
 batteryDischarge = "Batterie entladen"
+batteryGridChargeActive = "Netzladen aktiv"
+batteryGridChargeLimit = "Netzladen wenn"
 batteryHold = "Batterie (gesperrt)"
 batteryTooltip = "{energy} von {total} ({soc})"
-cheapBatteryGridCharge = "günstige Netzenergie"
-cleanBatteryGridCharge = "saubere Netzenergie"
 gridImport = "Netzbezug"
 homePower = "Verbrauch"
 loadpoints = "Ladepunkt | Ladepunkt | {count} Ladepunkte"
diff --git a/i18n/el.toml b/i18n/el.toml
index f3439690..ab4a6400 100644
--- a/i18n/el.toml
+++ b/i18n/el.toml
@@ -410,8 +410,6 @@ batteryCharge = "Φόρτιση μπαταρίας"
 batteryDischarge = "Αποφόρτιση μπαταρίας"
 batteryHold = "Μπαταρία (κλειδωμένο)"
 batteryTooltip = "{energy} από {total} ({soc})"
-cheapBatteryGridCharge = "φθηνή ενέργεια δικτύου"
-cleanBatteryGridCharge = "πράσινη ενέργεια δικτύου"
 gridImport = "Χρήση δικτύου"
 homePower = "Κατανάλωση"
 loadpoints = "Φορτιστής| Φορτιστής | {count} φορτιστές"
diff --git a/i18n/en.toml b/i18n/en.toml
index 2c81f7e8..6e289b9b 100644
--- a/i18n/en.toml
+++ b/i18n/en.toml
@@ -407,10 +407,10 @@ weekdays = "Days"
 battery = "Battery"
 batteryCharge = "Battery charging"
 batteryDischarge = "Battery discharging"
+batteryGridChargeActive = "grid charging active"
+batteryGridChargeLimit = "grid charging when"
 batteryHold = "Battery (locked)"
 batteryTooltip = "{energy} of {total} ({soc})"
-cheapBatteryGridCharge = "cheap grid energy"
-cleanBatteryGridCharge = "clean grid energy"
 gridImport = "Grid use"
 homePower = "Consumption"
 loadpoints = "Charger| Charger | {count} chargers"
diff --git a/i18n/es.toml b/i18n/es.toml
index 09c7185c..d058bd5b 100644
--- a/i18n/es.toml
+++ b/i18n/es.toml
@@ -428,8 +428,6 @@ batteryCharge = "Cargar la batería"
 batteryDischarge = "Descargar la batería"
 batteryHold = "Batería (bloqueada)"
 batteryTooltip = "{energy} de {total} ({soc})"
-cheapBatteryGridCharge = "Energía de red barata"
-cleanBatteryGridCharge = "Energía de red limpia"
 gridImport = "Consumo de red"
 homePower = "Consumo"
 loadpoints = "Punto de carga | Punto de carga | {count} Puntos de carga"
diff --git a/i18n/fi.toml b/i18n/fi.toml
index 2ac7b5ac..e6056a3f 100644
--- a/i18n/fi.toml
+++ b/i18n/fi.toml
@@ -419,8 +419,6 @@ batteryCharge = "Akunlataus"
 batteryDischarge = "Akku purkautuu"
 batteryHold = "Akku (lukittu)"
 batteryTooltip = "{energy} / {total} ({soc})"
-cheapBatteryGridCharge = "halpaa energiaa verkosta"
-cleanBatteryGridCharge = "puhdasta energiaa verkosta"
 gridImport = "Kulutus sähköverkosta"
 homePower = "Kodin sähkönkulutus"
 loadpoints = "Latauslaite| Latauslaite | {count} latauslaitetta"
diff --git a/i18n/fr.toml b/i18n/fr.toml
index 04ddea38..62412a76 100644
--- a/i18n/fr.toml
+++ b/i18n/fr.toml
@@ -428,8 +428,6 @@ batteryCharge = "Batterie en charge"
 batteryDischarge = "Décharge de la batterie"
 batteryHold = "Batterie (verrouillée)"
 batteryTooltip = "{energy} sur {total} ({soc})"
-cheapBatteryGridCharge = "énergie réseau bon marché"
-cleanBatteryGridCharge = "énergie réseau verte"
 gridImport = "Utilisation du réseau"
 homePower = "Consommation"
 loadpoints = "Recharge| Recharge | {count} chargeurs"
diff --git a/i18n/hr.toml b/i18n/hr.toml
index d7406b83..9c9c7afa 100644
--- a/i18n/hr.toml
+++ b/i18n/hr.toml
@@ -420,8 +420,6 @@ batteryCharge = "Punjenje baterije"
 batteryDischarge = "Pražnjenje baterije"
 batteryHold = "Baterija (zaključano)"
 batteryTooltip = "{energy} od {total} ({soc})"
-cheapBatteryGridCharge = "jeftina energije iz mreže"
-cleanBatteryGridCharge = "čista energija iz mreže"
 gridImport = "Upotreba mreže"
 homePower = "Potrošnja"
 loadpoints = "Punjač| Punjač | {count} punjača"
@@ -586,6 +584,7 @@ scale3p = "Uskoro se prebacuje na trofazno punjenje."
 targetChargeActive = "Aktivan je plan punjenja. Procijenjeni završetak za {duration}."
 targetChargePlanned = "Planirano punjenje počinje za {duration}."
 targetChargeWaitForVehicle = "Planirano punjenje je spremno. Čeka se na vozilo…"
+unknown = ""
 vehicleLimit = "Granica vozila."
 vehicleLimitReached = "Dosegnuto ograničenje na vozilu."
 waitForVehicle = "Spremno. Čeka se na vozilo …"
diff --git a/i18n/hu.toml b/i18n/hu.toml
index c11785a0..3cb54677 100644
--- a/i18n/hu.toml
+++ b/i18n/hu.toml
@@ -410,8 +410,6 @@ batteryCharge = "Energiatároló töltés"
 batteryDischarge = "Energiatároló kisütés"
 batteryHold = "Energiatároló (lezárva)"
 batteryTooltip = "{energy} / {total} ({soc})"
-cheapBatteryGridCharge = "olcsó hálózati energia"
-cleanBatteryGridCharge = "tiszta hálózati energia"
 gridImport = "Hálózatból import"
 homePower = "Fogyasztás"
 loadpoints = "Töltő| Töltő | {count} töltő"
diff --git a/i18n/it.toml b/i18n/it.toml
index 4b91132b..92d924f8 100644
--- a/i18n/it.toml
+++ b/i18n/it.toml
@@ -422,8 +422,6 @@ batteryCharge = "Carica della batteria"
 batteryDischarge = "Scarico della batteria"
 batteryHold = "Batteria (locked)"
 batteryTooltip = "{energy} di {total} ({soc})"
-cheapBatteryGridCharge = "energia di rete a basso costo"
-cleanBatteryGridCharge = "energia di rete pulita"
 gridImport = "Uso della rete"
 homePower = "Consumo"
 loadpoints = "Caricabatterie| Caricabatterie | {count} caricabatterie"
diff --git a/i18n/lb.toml b/i18n/lb.toml
index ab740e99..3c4a87f7 100644
--- a/i18n/lb.toml
+++ b/i18n/lb.toml
@@ -421,8 +421,6 @@ batteryCharge = "Batterie oplueden"
 batteryDischarge = "Batterie entlueden"
 batteryHold = "Batterie (gespäert)"
 batteryTooltip = "{energy} vun {total} ({soc})"
-cheapBatteryGridCharge = "Gënschtege Stroum vum Netz"
-cleanBatteryGridCharge = "grénge Stroum vum Netz"
 gridImport = "Verbrauch vum Stroumnetz"
 homePower = "Verbrauch"
 loadpoints = "Wallbox | Wallbox | {count} Wallboxen"
diff --git a/i18n/lt.toml b/i18n/lt.toml
index 9b54f212..4dc18dc0 100644
--- a/i18n/lt.toml
+++ b/i18n/lt.toml
@@ -428,8 +428,6 @@ batteryCharge = "Kaupiklis įkraunamas"
 batteryDischarge = "Kaupiklis iškraunamas"
 batteryHold = "Kaupiklis (užblokuotas)"
 batteryTooltip = "{energy} iš {total} ({soc})"
-cheapBatteryGridCharge = "pigi tinklo energija"
-cleanBatteryGridCharge = "švari tinklo energija"
 gridImport = "Iš tinklo"
 homePower = "Namo suvartojimas"
 loadpoints = "Įkroviklis | Įkroviklis | {count} įkrovikliai"
diff --git a/i18n/nl.toml b/i18n/nl.toml
index 1d393471..f33b818e 100644
--- a/i18n/nl.toml
+++ b/i18n/nl.toml
@@ -425,8 +425,6 @@ batteryCharge = "Batterij opladen"
 batteryDischarge = "Batterij ontladen"
 batteryHold = "Batterij (aanhouden)"
 batteryTooltip = "{energy} van {total} ({soc})"
-cheapBatteryGridCharge = "goedkope net-energie"
-cleanBatteryGridCharge = "schone net-energie"
 gridImport = "Netafname"
 homePower = "Consumptie"
 loadpoints = "Lader| Lader | {count} laders"
diff --git a/i18n/pt.toml b/i18n/pt.toml
index d8401e66..a512b1a7 100644
--- a/i18n/pt.toml
+++ b/i18n/pt.toml
@@ -424,8 +424,6 @@ batteryCharge = "Carrego de bateria"
 batteryDischarge = "Descarrego de bateria"
 batteryHold = "Bateria (suspensa)"
 batteryTooltip = "{energy} de {total} ({soc})"
-cheapBatteryGridCharge = "energia de rede barata"
-cleanBatteryGridCharge = "energia de rede verde"
 gridImport = "Consumo de rede"
 homePower = "Consumo"
 loadpoints = "Carregador | Carregador | {count} Carregadores"
diff --git a/i18n/sl.toml b/i18n/sl.toml
index d44c5349..5e8bcb37 100644
--- a/i18n/sl.toml
+++ b/i18n/sl.toml
@@ -417,8 +417,6 @@ batteryCharge = "Polnjenje baterije"
 batteryDischarge = "Praznjenje baterije"
 batteryHold = "Baterija (zaklenjena)"
 batteryTooltip = "{energy} od {total} ({soc})"
-cheapBatteryGridCharge = "poceni omrežna energija"
-cleanBatteryGridCharge = "energija iz čistega omrežja"
 gridImport = "Uvoz iz omrežja"
 homePower = "Poraba objekta"
 loadpoints = "Polnilnica | Polnilnici | {count} polnilnih mest"
diff --git a/i18n/sv.toml b/i18n/sv.toml
index e5c35ea1..d16e0142 100644
--- a/i18n/sv.toml
+++ b/i18n/sv.toml
@@ -423,8 +423,6 @@ batteryCharge = "Batteri laddas"
 batteryDischarge = "Batteri laddas ur"
 batteryHold = "Batteri (låst)"
 batteryTooltip = "{energy} av {total} ({soc})"
-cheapBatteryGridCharge = "billig nät-energi"
-cleanBatteryGridCharge = "grön nät-energi"
 gridImport = "Import från elnät"
 homePower = "Konsumtion"
 loadpoints = "Laddare | Laddare | {count} laddare"
diff --git a/i18n/ta.toml b/i18n/ta.toml
index f02a059c..b2f97f2a 100644
--- a/i18n/ta.toml
+++ b/i18n/ta.toml
@@ -409,8 +409,6 @@ batteryCharge = "Battery charging"
 batteryDischarge = "Battery discharging"
 batteryHold = "Battery (locked)"
 batteryTooltip = "{energy} of {total} ({soc})"
-cheapBatteryGridCharge = "cheap வலைவாய் energy"
-cleanBatteryGridCharge = "clean வலைவாய் energy"
 gridImport = "Grid use"
 homePower = "Consumption"
 loadpoints = "Charger| மின்னூட்டி | {count} chargers"
diff --git a/i18n/tr.toml b/i18n/tr.toml
index bd6e2e80..24e48355 100644
--- a/i18n/tr.toml
+++ b/i18n/tr.toml
@@ -402,7 +402,7 @@ titleMinSoc = "Asgari doldurma"
 titleTargetCharge = "Ayrılış"
 unsavedChanges = "Kaydedilmemiş değişiklikler var. Şimdi uygulansın mı?"
 update = "Uygula"
-weekdays = "“Hafta günleri”"
+weekdays = "Günler"
 
 [main.energyflow]
 battery = "Batarya"
@@ -410,8 +410,6 @@ batteryCharge = "Batarya doldurma"
 batteryDischarge = "Batarya boşaltma"
 batteryHold = "Batarya (kilitli)"
 batteryTooltip = "{total} ({soc})'ın {energy}'ı"
-cheapBatteryGridCharge = "“ucuz şebeke enerjisi”"
-cleanBatteryGridCharge = "“temiz şebeke enerjisi”"
 gridImport = "Şebeke kullanımı"
 homePower = "Tüketim"
 loadpoints = "Doldurma Cihazı| Doldurma Cihazı | {count} doldurma cihazları"
@@ -576,6 +574,7 @@ scale3p = "Birazdan 3 fazla doldurmaya yükseltilecek."
 targetChargeActive = "Doldurma planı yürürlükte. Tahmini bitiş süresi {duration} içerisinde."
 targetChargePlanned = "Doldurma planı {duration} içerisinde başlayacak."
 targetChargeWaitForVehicle = "Doldurma planı hazır. Araç bekleniyor…"
+unknown = ""
 vehicleLimit = "Araç sınırı."
 vehicleLimitReached = "Araç sınırına ulaşıldı."
 vehicleTargetReached = "Araç sınırı {soc} ulaşıldı."
diff --git a/meter/e3dc.go b/meter/e3dc.go
index 89c53019..1e4fc45e 100644
--- a/meter/e3dc.go
+++ b/meter/e3dc.go
@@ -176,7 +176,7 @@ func (m *E3dc) setBatteryMode(mode api.BatteryMode) error {
 	case api.BatteryCharge:
 		res, err = m.conn.SendMultiple([]rscp.Message{
 			e3dcDischargeBatteryLimit(false, 0),
-			e3dcBatteryCharge(10000), // 10kWh
+			e3dcBatteryCharge(50000), // max. 50kWh
 		})
 
 	default:
diff --git a/meter/openwb.go b/meter/openwb.go
index bfd0c175..371a876a 100644
--- a/meter/openwb.go
+++ b/meter/openwb.go
@@ -65,7 +65,7 @@ func NewOpenWBFromConfig(other map[string]interface{}) (api.Meter, error) {
 		}
 
 		var curr [3]func() (float64, error)
-		for i := 0; i < 3; i++ {
+		for i := range 3 {
 			current, err := to.FloatGetter(mq("%s/evu/%s%d", cc.Topic, openwb.CurrentTopic, i+1))
 			if err != nil {
 				return nil, err
diff --git a/meter/tapo/connection.go b/meter/tapo/connection.go
index d932ae2a..2431aeb3 100644
--- a/meter/tapo/connection.go
+++ b/meter/tapo/connection.go
@@ -6,6 +6,7 @@ import (
 	"net/url"
 	"strings"
 
+	"github.com/evcc-io/evcc/api"
 	"github.com/evcc-io/evcc/util"
 	"github.com/insomniacslk/tapo"
 )
@@ -33,7 +34,7 @@ func NewConnection(uri, user, password string) (*Connection, error) {
 	}
 
 	if user == "" || password == "" {
-		return nil, fmt.Errorf("missing user or password")
+		return nil, api.ErrMissingCredentials
 	}
 
 	log := util.NewLogger("tapo").Redact(user, password)
diff --git a/meter/tibber-pulse.go b/meter/tibber-pulse.go
index bf6abe02..6e1f36bc 100644
--- a/meter/tibber-pulse.go
+++ b/meter/tibber-pulse.go
@@ -3,7 +3,6 @@ package meter
 import (
 	"context"
 	"encoding/json"
-	"errors"
 	"net/http"
 	"strings"
 	"time"
@@ -38,7 +37,7 @@ func NewTibberFromConfig(ctx context.Context, other map[string]interface{}) (api
 	}
 
 	if cc.Token == "" {
-		return nil, errors.New("missing token")
+		return nil, api.ErrMissingToken
 	}
 
 	log := util.NewLogger("pulse").Redact(cc.Token, cc.HomeID)
diff --git a/meter/zendure/connection.go b/meter/zendure/connection.go
index da819469..f1c7de9f 100644
--- a/meter/zendure/connection.go
+++ b/meter/zendure/connection.go
@@ -73,12 +73,10 @@ func (c *Connection) handler(data string) {
 	}
 
 	c.data.SetFunc(func(v Data) Data {
-		if err := mergo.Merge(&v, res.Data); err != nil {
+		if err := mergo.Merge(&v, res.Data, mergo.WithOverride); err != nil {
 			c.log.ERROR.Println(err)
 		}
 
-		c.log.TRACE.Printf("!! data: %+v", v)
-
 		return v
 	})
 }
diff --git a/provider/charger.go b/provider/charger.go
new file mode 100644
index 00000000..6696f331
--- /dev/null
+++ b/provider/charger.go
@@ -0,0 +1,87 @@
+package provider
+
+import (
+	"context"
+
+	"github.com/evcc-io/evcc/api"
+	charger "github.com/evcc-io/evcc/charger/config"
+	"github.com/evcc-io/evcc/util"
+	"github.com/evcc-io/evcc/util/config"
+	"github.com/spf13/cast"
+)
+
+type switchChargerProvider struct {
+	charger api.Charger
+}
+
+func init() {
+	registry.AddCtx("charger", NewChargerEnableFromConfig)
+}
+
+// NewChargerEnableFromConfig creates type conversion provider
+func NewChargerEnableFromConfig(ctx context.Context, other map[string]interface{}) (Provider, error) {
+	var cc struct {
+		Config config.Typed
+	}
+
+	if err := util.DecodeOther(other, &cc); err != nil {
+		return nil, err
+	}
+
+	charger, err := charger.NewFromConfig(ctx, cc.Config.Type, cc.Config.Other)
+	if err != nil {
+		return nil, err
+	}
+
+	o := &switchChargerProvider{
+		charger: charger,
+	}
+
+	return o, nil
+}
+
+var _ FloatProvider = (*switchChargerProvider)(nil)
+
+func (o *switchChargerProvider) FloatGetter() (func() (float64, error), error) {
+	return func() (float64, error) {
+		v, err := o.charger.Enabled()
+		return cast.ToFloat64(v), err
+	}, nil
+}
+
+var _ IntProvider = (*switchChargerProvider)(nil)
+
+func (o *switchChargerProvider) IntGetter() (func() (int64, error), error) {
+	return func() (int64, error) {
+		v, err := o.charger.Enabled()
+		return cast.ToInt64(v), err
+	}, nil
+}
+
+var _ SetIntProvider = (*switchChargerProvider)(nil)
+
+func (o *switchChargerProvider) IntSetter(param string) (func(int64) error, error) {
+	return func(val int64) error {
+		b, err := cast.ToBoolE(val)
+		if err != nil {
+			return err
+		}
+		return o.charger.Enable(b)
+	}, nil
+}
+
+var _ BoolProvider = (*switchChargerProvider)(nil)
+
+func (o *switchChargerProvider) BoolGetter() (func() (bool, error), error) {
+	return func() (bool, error) {
+		return o.charger.Enabled()
+	}, nil
+}
+
+var _ SetBoolProvider = (*switchChargerProvider)(nil)
+
+func (o *switchChargerProvider) BoolSetter(param string) (func(bool) error, error) {
+	return func(val bool) error {
+		return o.charger.Enable(val)
+	}, nil
+}
diff --git a/provider/const.go b/provider/const.go
index 638e048c..d82b3b9c 100644
--- a/provider/const.go
+++ b/provider/const.go
@@ -11,7 +11,6 @@ import (
 )
 
 type constProvider struct {
-	*getter
 	ctx context.Context
 	str string
 	set Config
@@ -33,24 +32,12 @@ func NewConstFromConfig(ctx context.Context, other map[string]interface{}) (Prov
 		return nil, err
 	}
 
-	pipe, err := pipeline.New(nil, cc.Settings)
-	if err != nil {
-		return nil, err
-	}
-
-	b, err := pipe.Process([]byte(cc.Value))
-	if err != nil {
-		return nil, err
-	}
-
 	p := &constProvider{
 		ctx: ctx,
-		str: string(b),
+		str: cc.Value,
 		set: cc.Set,
 	}
 
-	p.getter = defaultGetters(p, 1)
-
 	return p, nil
 }
 
@@ -62,6 +49,45 @@ func (p *constProvider) StringGetter() (func() (string, error), error) {
 	}, nil
 }
 
+var _ IntProvider = (*constProvider)(nil)
+
+func (p *constProvider) IntGetter() (func() (int64, error), error) {
+	val, err := strconv.ParseInt(p.str, 10, 64)
+	if err != nil && p.str == "" {
+		err = nil
+	}
+
+	return func() (int64, error) {
+		return val, err
+	}, err
+}
+
+var _ FloatProvider = (*constProvider)(nil)
+
+func (p *constProvider) FloatGetter() (func() (float64, error), error) {
+	val, err := strconv.ParseFloat(p.str, 64)
+	if err != nil && p.str == "" {
+		err = nil
+	}
+
+	return func() (float64, error) {
+		return val, err
+	}, err
+}
+
+var _ BoolProvider = (*constProvider)(nil)
+
+func (p *constProvider) BoolGetter() (func() (bool, error), error) {
+	val, err := strconv.ParseBool(p.str)
+	if err != nil && p.str == "" {
+		err = nil
+	}
+
+	return func() (bool, error) {
+		return val, err
+	}, err
+}
+
 var _ SetIntProvider = (*constProvider)(nil)
 
 func (p *constProvider) IntSetter(param string) (func(int64) error, error) {
diff --git a/provider/golang/registry.go b/provider/golang/registry.go
index 21d024f6..72f0bcf2 100644
--- a/provider/golang/registry.go
+++ b/provider/golang/registry.go
@@ -8,13 +8,6 @@ import (
 	"github.com/traefik/yaegi/interp"
 )
 
-const Imports = `import (
-	"fmt"
-	"math"
-	"strings"
-	"time"
-)`
-
 var (
 	mu       sync.Mutex
 	registry = make(map[string]*interp.Interpreter)
@@ -31,14 +24,10 @@ func RegisteredVM(name, init string) (*interp.Interpreter, error) {
 	// create new VM
 	if !ok {
 		vm = interp.New(interp.Options{})
-
 		if err := vm.Use(stdlib.Symbols); err != nil {
 			return nil, err
 		}
-
-		if _, err := vm.Eval(Imports); err != nil {
-			return nil, err
-		}
+		vm.ImportUsed()
 
 		if init != "" {
 			if _, err := vm.Eval(init); err != nil {
diff --git a/provider/http.go b/provider/http.go
index 4fca739d..6bbe7ad5 100644
--- a/provider/http.go
+++ b/provider/http.go
@@ -34,6 +34,8 @@ func init() {
 	registry.AddCtx("http", NewHTTPProviderFromConfig)
 }
 
+var mc = httpcache.NewMemoryCache()
+
 // Auth is the authorization config
 type Auth struct {
 	Type, User, Password string
@@ -101,9 +103,10 @@ func NewHTTP(log *util.Logger, method, uri string, insecure bool, cache time.Dur
 	}
 
 	// http cache
-	cacheTransport := httpcache.NewMemoryCacheTransport()
-	cacheTransport.Transport = p.Client.Transport
-	p.Client.Transport = cacheTransport
+	p.Client.Transport = &httpcache.Transport{
+		Cache:     mc,
+		Transport: p.Client.Transport,
+	}
 
 	// ignore the self signed certificate
 	if insecure {
diff --git a/provider/pipeline/pipeline.go b/provider/pipeline/pipeline.go
index fd7fc68d..1e2e1bc1 100644
--- a/provider/pipeline/pipeline.go
+++ b/provider/pipeline/pipeline.go
@@ -21,6 +21,7 @@ type Pipeline struct {
 	re         *regexp.Regexp
 	jq         *gojq.Query
 	allowEmpty bool
+	quote      bool
 	dflt       string
 	unpack     string
 	decode     string
@@ -28,6 +29,7 @@ type Pipeline struct {
 
 type Settings struct {
 	AllowEmpty bool
+	Quote      bool
 	Regex      string
 	Default    string
 	Jq         string
@@ -39,6 +41,7 @@ func New(log *util.Logger, cc Settings) (*Pipeline, error) {
 	p := &Pipeline{
 		log:        log,
 		allowEmpty: cc.AllowEmpty,
+		quote:      cc.Quote,
 	}
 
 	var err error
@@ -191,6 +194,9 @@ func (p *Pipeline) Process(in []byte) ([]byte, error) {
 	}
 
 	if p.jq != nil {
+		if p.quote {
+			b = []byte(fmt.Sprintf("%q", string(b)))
+		}
 		v, err := jq.Query(p.jq, b)
 		if err != nil {
 			return b, backoff.Permanent(err)
diff --git a/provider/timeseries.go b/provider/timeseries.go
new file mode 100644
index 00000000..38c09914
--- /dev/null
+++ b/provider/timeseries.go
@@ -0,0 +1,40 @@
+package provider
+
+import (
+	"context"
+	"encoding/json"
+	"time"
+
+	"github.com/evcc-io/evcc/api"
+	"github.com/jinzhu/now"
+)
+
+type timeseriesProvider struct{}
+
+func init() {
+	registry.AddCtx("timeseries", TimeSeriesFromConfig)
+}
+
+// TimeSeriesFromConfig creates timeseries provider
+func TimeSeriesFromConfig(_ context.Context, _ map[string]interface{}) (Provider, error) {
+	return new(timeseriesProvider), nil
+}
+
+var _ StringProvider = (*timeseriesProvider)(nil)
+
+func (p *timeseriesProvider) StringGetter() (func() (string, error), error) {
+	return func() (string, error) {
+		res := make(api.Rates, 48)
+		ts := now.BeginningOfHour()
+		for i := range 48 {
+			res[i] = api.Rate{
+				Start: ts,
+				End:   ts.Add(time.Hour),
+			}
+			ts = ts.Add(time.Hour)
+		}
+
+		b, err := json.Marshal(res)
+		return string(b), err
+	}, nil
+}
diff --git a/server/eebus/types.go b/server/eebus/types.go
index 6ad12480..1da233e5 100644
--- a/server/eebus/types.go
+++ b/server/eebus/types.go
@@ -1,11 +1,15 @@
 package eebus
 
+import "github.com/evcc-io/evcc/util"
+
 const (
-	BrandName  string = "EVCC"
-	Model      string = "HEMS"
-	DeviceCode string = "EVCC_HEMS_01" // used as common name in cert generation
+	BrandName string = "EVCC"
+	Model     string = "HEMS"
 )
 
+// used as common name in cert generation
+var DeviceCode = util.Getenv("EEBUS_DEVICE_CODE", "EVCC_HEMS_01")
+
 type Config struct {
 	URI         string
 	ShipID      string
diff --git a/server/helper.go b/server/helper.go
index 881b6a99..43cd1206 100644
--- a/server/helper.go
+++ b/server/helper.go
@@ -3,7 +3,10 @@ package server
 import (
 	"fmt"
 	"math"
+	"reflect"
+	"slices"
 	"strconv"
+	"strings"
 )
 
 // pass converts a simple api without return value to api with nil error return value
@@ -22,3 +25,10 @@ func parseFloat(payload string) (float64, error) {
 	}
 	return f, err
 }
+
+// omitEmpty returns true if struct field is omitempty
+func omitEmpty(f reflect.StructField) bool {
+	tag := f.Tag.Get("json")
+	values := strings.Split(tag, ",")
+	return slices.Contains(values, "omitempty")
+}
diff --git a/server/influxdb.go b/server/influxdb.go
index a87b11ba..0e331ecc 100644
--- a/server/influxdb.go
+++ b/server/influxdb.go
@@ -67,66 +67,84 @@ func (m *Influx) writePoint(writer pointWriter, key string, fields map[string]an
 }
 
 // writeComplexPoint asynchronously writes a point to influx
-func (m *Influx) writeComplexPoint(writer pointWriter, param util.Param, tags map[string]string) {
+func (m *Influx) writeComplexPoint(writer pointWriter, key string, val any, tags map[string]string) {
 	fields := make(map[string]any)
 
-	switch val := param.Val.(type) {
+	// loop struct
+	writeStruct := func(sv any) {
+		typ := reflect.TypeOf(sv)
+		val := reflect.ValueOf(sv)
+
+		for i := range typ.NumField() {
+			if f := typ.Field(i); f.IsExported() {
+				if val.Field(i).IsZero() && omitEmpty(f) {
+					continue
+				}
+
+				key := key + strings.ToUpper(f.Name[:1]) + f.Name[1:]
+				val := val.Field(i).Interface()
+
+				m.writeComplexPoint(writer, key, val, tags)
+			}
+		}
+	}
+
+	switch valueType := val.(type) {
 	case string:
 		return
 
 	case int, int64, float64:
-		fields["value"] = param.Val
+		fields["value"] = val
 
 	case []float64:
-		if len(val) != 3 {
+		if len(valueType) != 3 {
 			return
 		}
 
 		// add array as phase values
-		for i, v := range val {
+		for i, v := range valueType {
 			fields[fmt.Sprintf("l%d", i+1)] = v
 		}
 
 	case [3]float64:
 		// add array as phase values
-		for i, v := range val {
+		for i, v := range valueType {
 			fields[fmt.Sprintf("l%d", i+1)] = v
 		}
 
 	default:
 		// allow writing nil values
-		if param.Val == nil {
+		if val == nil {
 			fields["value"] = nil
 			break
 		}
 
-		// slice of structs
-		if typ := reflect.TypeOf(param.Val); typ.Kind() == reflect.Slice && typ.Elem().Kind() == reflect.Struct {
-			val := reflect.ValueOf(param.Val)
-
-			// loop slice
-			for i := 0; i < val.Len(); i++ {
-				val := val.Index(i)
-				typ := val.Type()
+		switch typ := reflect.TypeOf(val); {
+		// pointer
+		case typ.Kind() == reflect.Ptr:
+			if val := reflect.ValueOf(val); !val.IsNil() {
+				m.writeComplexPoint(writer, key, reflect.Indirect(val).Interface(), tags)
+			}
 
-				// loop struct
-				for j := 0; j < typ.NumField(); j++ {
-					n := typ.Field(j).Name
-					v := val.Field(j).Interface()
+		// struct
+		case typ.Kind() == reflect.Struct:
+			writeStruct(val)
 
-					key := param.Key + strings.ToUpper(n[:1]) + n[1:]
-					fields["value"] = v
-					tags["id"] = strconv.Itoa(i + 1)
+		// slice of structs
+		case typ.Kind() == reflect.Slice && typ.Elem().Kind() == reflect.Struct:
+			val := reflect.ValueOf(val)
 
-					m.writePoint(writer, key, fields, tags)
-				}
+			// loop slice
+			for i := range val.Len() {
+				tags["id"] = strconv.Itoa(i + 1)
+				writeStruct(val.Index(i).Interface())
 			}
 		}
 
 		return
 	}
 
-	m.writePoint(writer, param.Key, fields, tags)
+	m.writePoint(writer, key, fields, tags)
 }
 
 // Run Influx publisher
@@ -153,7 +171,7 @@ func (m *Influx) Run(site site.API, in <-chan util.Param) {
 			}
 		}
 
-		m.writeComplexPoint(writer, param, tags)
+		m.writeComplexPoint(writer, param.Key, param.Val, tags)
 	}
 
 	m.client.Close()
diff --git a/server/mqtt.go b/server/mqtt.go
index d9f4f692..f36f9d57 100644
--- a/server/mqtt.go
+++ b/server/mqtt.go
@@ -83,7 +83,7 @@ func (m *MQTT) publishComplex(topic string, retained bool, payload interface{})
 		m.publishSingleValue(topic, retained, val.Len())
 
 		// loop slice
-		for i := 0; i < val.Len(); i++ {
+		for i := range val.Len() {
 			m.publishComplex(fmt.Sprintf("%s/%d", topic, i+1), retained, val.Index(i).Interface())
 		}
 
@@ -99,16 +99,21 @@ func (m *MQTT) publishComplex(topic string, retained bool, payload interface{})
 		typ := val.Type()
 
 		// loop struct
-		for i := 0; i < typ.NumField(); i++ {
+		for i := range typ.NumField() {
 			if f := typ.Field(i); f.IsExported() {
-				n := f.Name
-				m.publishComplex(fmt.Sprintf("%s/%s", topic, strings.ToLower(n[:1])+n[1:]), retained, val.Field(i).Interface())
+				topic := fmt.Sprintf("%s/%s", topic, strings.ToLower(f.Name[:1])+f.Name[1:])
+
+				if val.Field(i).IsZero() && omitEmpty(f) {
+					m.publishSingleValue(topic, retained, nil)
+				} else {
+					m.publishComplex(topic, retained, val.Field(i).Interface())
+				}
 			}
 		}
 
 	case reflect.Pointer:
-		if !reflect.ValueOf(payload).IsNil() {
-			m.publishComplex(topic, retained, reflect.Indirect(reflect.ValueOf(payload)).Interface())
+		if val := reflect.ValueOf(payload); !val.IsNil() {
+			m.publishComplex(topic, retained, reflect.Indirect(val).Interface())
 			return
 		}
 
@@ -273,7 +278,7 @@ func (m *MQTT) Run(site site.API, in <-chan util.Param) {
 	topic = fmt.Sprintf("%s/vehicles", m.root)
 	m.publish(topic, true, len(site.Vehicles().Settings()))
 
-	for i := 0; i < 10; i++ {
+	for i := range 10 {
 		m.publish(fmt.Sprintf("%s/site/pv/%d", m.root, i), true, nil)
 		m.publish(fmt.Sprintf("%s/site/battery/%d", m.root, i), true, nil)
 		m.publish(fmt.Sprintf("%s/site/vehicles/%d", m.root, i), true, nil)
diff --git a/server/socket_helper.go b/server/socket_helper.go
index c512da66..e16052e1 100644
--- a/server/socket_helper.go
+++ b/server/socket_helper.go
@@ -21,7 +21,7 @@ func encodeSliceAsString(v any) (string, error) {
 	rv := reflect.ValueOf(v)
 	res := make([]string, rv.Len())
 
-	for i := 0; i < rv.Len(); i++ {
+	for i := range rv.Len() {
 		var err error
 		if res[i], err = encodeAsString(rv.Index(i).Interface()); err != nil {
 			return "", err
diff --git a/tariff/amber.go b/tariff/amber.go
index b60748bc..fd00d833 100644
--- a/tariff/amber.go
+++ b/tariff/amber.go
@@ -44,7 +44,7 @@ func NewAmberFromConfig(other map[string]interface{}) (api.Tariff, error) {
 	}
 
 	if cc.Token == "" {
-		return nil, errors.New("missing token")
+		return nil, api.ErrMissingToken
 	}
 
 	if cc.SiteID == "" {
@@ -83,7 +83,7 @@ func NewAmberFromConfig(other map[string]interface{}) (api.Tariff, error) {
 func (t *Amber) run(done chan error) {
 	var once sync.Once
 
-	for tick := time.Tick(time.Hour); ; <-tick {
+	for tick := time.Tick(time.Minute); ; <-tick {
 		var res []amber.PriceInfo
 		uri := fmt.Sprintf("%s&endDate=%s", t.uri, time.Now().AddDate(0, 0, 2).Format("2006-01-02"))
 
diff --git a/tariff/embed.go b/tariff/embed.go
index c9c523a3..effc0213 100644
--- a/tariff/embed.go
+++ b/tariff/embed.go
@@ -5,7 +5,6 @@ import (
 	"fmt"
 	"time"
 
-	"github.com/evcc-io/evcc/provider/golang"
 	"github.com/evcc-io/evcc/provider/golang/stdlib"
 	"github.com/traefik/yaegi/interp"
 )
@@ -29,35 +28,24 @@ func (t *embed) init() (err error) {
 		return nil
 	}
 
-	vm := interp.New(interp.Options{})
-	if err := vm.Use(stdlib.Symbols); err != nil {
-		return err
-	}
-
-	if _, err := vm.Eval(fmt.Sprintf(`%s
-	var (
-		price float64
-		charges float64 = %f
-		tax float64 = %f
-		ts time.Time
-	)`, golang.Imports, t.Charges, t.Tax)); err != nil {
-		return err
-	}
-
-	prg, err := vm.Compile(t.Formula)
-	if err != nil {
-		return err
-	}
-
 	t.calc = func(price float64, ts time.Time) (float64, error) {
+		vm := interp.New(interp.Options{})
+		if err := vm.Use(stdlib.Symbols); err != nil {
+			return 0, err
+		}
+		vm.ImportUsed()
+
 		if _, err := vm.Eval(fmt.Sprintf(`
-			price = %f
+		var (
+			price float64 = %f
+			charges float64 = %f
+			tax float64 = %f
 			ts = time.Unix(%d, 0).Local()
-		`, price, ts.Unix())); err != nil {
+		)`, price, t.Charges, t.Tax, ts.Unix())); err != nil {
 			return 0, err
 		}
 
-		res, err := vm.Execute(prg)
+		res, err := vm.Eval(t.Formula)
 		if err != nil {
 			return 0, err
 		}
diff --git a/tariff/fixed.go b/tariff/fixed.go
index 71f5753a..c40e2e5d 100644
--- a/tariff/fixed.go
+++ b/tariff/fixed.go
@@ -85,7 +85,7 @@ func (t *Fixed) Rates() (api.Rates, error) {
 	var res api.Rates
 
 	start := now.With(t.clock.Now().Local()).BeginningOfDay()
-	for i := 0; i < 7; i++ {
+	for i := range 7 {
 		dow := fixed.Day((int(start.Weekday()) + i) % 7)
 
 		zones := t.zones.ForDay(dow)
diff --git a/tariff/tibber.go b/tariff/tibber.go
index 155d1082..d79c8edf 100644
--- a/tariff/tibber.go
+++ b/tariff/tibber.go
@@ -2,7 +2,6 @@ package tariff
 
 import (
 	"context"
-	"errors"
 	"slices"
 	"sync"
 	"time"
@@ -41,7 +40,7 @@ func NewTibberFromConfig(other map[string]interface{}) (api.Tariff, error) {
 	}
 
 	if cc.Token == "" {
-		return nil, errors.New("missing token")
+		return nil, api.ErrMissingToken
 	}
 
 	if err := cc.init(); err != nil {
diff --git a/templates/definition/charger/ac-thor.yaml b/templates/definition/charger/ac-thor.yaml
new file mode 100644
index 00000000..534b287c
--- /dev/null
+++ b/templates/definition/charger/ac-thor.yaml
@@ -0,0 +1,14 @@
+template: ac-thor
+products:
+  - brand: my-PV
+    description:
+      generic: AC THOR, AC THOR 9s
+group: heating
+requirements:
+  evcc: ["sponsorship"]
+params:
+  - name: modbus
+    choice: ["tcpip"]
+render: |
+  type: ac-thor
+  {{- include "modbus" . }}
diff --git a/templates/definition/charger/go-e-v3.yaml b/templates/definition/charger/go-e-v3.yaml
index af427d07..db523f02 100644
--- a/templates/definition/charger/go-e-v3.yaml
+++ b/templates/definition/charger/go-e-v3.yaml
@@ -9,19 +9,15 @@ requirements:
   description:
     de: |
       Benötigt mindestens Firmware 052.1 oder neuer.
-
       Für 1P/3P-Phasenumschaltung muss die HTTP API v2 im Charger aktiviert sein.
-
       In der Go-E App (Menüpunkt "Auto") sollte die Option "Ausstecken simulieren" aktiviert sein.
     en: |
       Requires firmware 052.1 or later.
-
       For 1P/3P-Phase switching the HTTP API v2 in the charger setup needs to be activated.
-
       The “simulate unplugging” option should be activated in the Go-E app ("Car" menu item).
   evcc: ["sponsorship"]
 params:
   - name: host
 render: |
-  type: go-e
+  type: go-e-v3
   uri: http://{{ .host }}
diff --git a/templates/definition/charger/ocpp-fronius-wattpilot.yaml b/templates/definition/charger/ocpp-fronius-wattpilot.yaml
index 03bf03e2..b296f4ee 100644
--- a/templates/definition/charger/ocpp-fronius-wattpilot.yaml
+++ b/templates/definition/charger/ocpp-fronius-wattpilot.yaml
@@ -4,6 +4,15 @@ products:
     description:
       generic: Wattpilot (OCPP)
 requirements:
+  description:
+    de: |
+      Hinweis zur Konfiguration des Wattpilot mittels solar.wattpilot app: das Symbol neben dem 
+      Beispiel-OCPP-Server "wss://<username>:<password>@ocpp.cloud.fronius.com/<UID>" dient zur Konfiguration. 
+      Hier muss der evcc URL "ws://<IP-vom-evcc-host>:8887/ eingetragen sein und "Füge SN zur URL" aktiviert werden.
+    en: |
+      Note on configuring the Wattpilot using the solar.wattpilot app: The symbol next to the 
+      example OCPP server "wss://<username>:<password>@ocpp.cloud.fronius.com/<UID>" is used for configuration.
+      The evcc URL "ws://<IP-of-the-evcc-host>:8887/" must be entered here and "Add SN to URL" must be activated.
   evcc: ["sponsorship", "skiptest"]
 params:
   - preset: ocpp
diff --git a/templates/definition/charger/weishaupt-wpm.yaml b/templates/definition/charger/weishaupt-wpm.yaml
new file mode 100644
index 00000000..1a45bb1a
--- /dev/null
+++ b/templates/definition/charger/weishaupt-wpm.yaml
@@ -0,0 +1,113 @@
+template: weishaupt-wpm
+products:
+  - brand: Weishaupt
+    description:
+      generic: WPM (SG Ready)
+group: heating
+# requirements:
+#   evcc: ["sponsorship"]
+params:
+  - name: modbus
+    choice: ["tcpip"]
+  - name: tempsource
+    type: choice
+    choice: ["", "warmwater", "buffer"]
+    description:
+      de: "Temperaturquelle"
+      en: "Temperature source"
+render: |
+  type: sgready
+  getmode:
+    source: map
+    values:
+      8:  1 # ABTAUEN
+      10: 3 # EVU_SPERRE
+      14: 2 # ERHOEHTER_BETRIEB
+      18: 3 # FROSTSCHUTZ
+      19: 1 # HEIZBETRIEB
+    get:
+      source: modbus
+      {{- include "modbus" . | indent 4 }}
+      register:
+        address: 30006
+        type: input
+        encoding: uint16
+  setmode:
+    source: switch
+    switch:
+    - case: 1 # normal
+      set:
+        source: sequence
+        set:
+        - source: const
+          value: 0
+          set:
+            source: modbus
+            {{- include "modbus" . | indent 10 }}
+            register:
+              address: 45101 # 4001
+              type: writesingle
+              encoding: uint16
+        - source: const
+          value: 0
+          set:
+            source: modbus
+            {{- include "modbus" . | indent 10 }}
+            register:
+              address: 45102 # 4002
+              type: writesingle
+              encoding: uint16
+    - case: 2 # boost
+      set:
+        source: sequence
+        set:
+        - source: const
+          value: 1
+          set:
+            source: modbus
+            {{- include "modbus" . | indent 10 }}
+            register:
+              address: 45101
+              type: writesingle
+              encoding: uint16
+        - source: const
+          value: 0
+          set:
+            source: modbus
+            {{- include "modbus" . | indent 10 }}
+            register:
+              address: 45102
+              type: writesingle
+              encoding: uint16
+    - case: 3 # dimm
+      set:
+        source: sequence
+        set:
+        - source: const
+          value: 0
+          set:
+            source: modbus
+            {{- include "modbus" . | indent 10 }}
+            register:
+              address: 45101
+              type: writesingle
+              encoding: uint16
+        - source: const
+          value: 1
+          set:
+            source: modbus
+            {{- include "modbus" . | indent 10 }}
+            register:
+              address: 45102
+              type: writesingle
+              encoding: uint16
+  {{- if .tempsource }}
+  temp:
+    source: modbus
+    {{- include "modbus" . | indent 2 }}
+    register:
+      address: {{ if eq .tempsource "warmwater" -}} 32102 {{ else }} 33104 {{- end }} # 32102 = WW; 33104 Vorlauf
+      type: input
+      encoding: int16
+    scale: 0.1
+  {{- end }}
diff --git a/templates/definition/common-schema.json b/templates/definition/common-schema.json
index 3d03aae5..0e8c41a1 100644
--- a/templates/definition/common-schema.json
+++ b/templates/definition/common-schema.json
@@ -199,7 +199,6 @@
           "enum": [
             "string",
             "bool",
-            "number",
             "float",
             "int",
             "list",
@@ -207,12 +206,6 @@
             "duration"
           ]
         },
-        "validvalues": {
-          "type": "array",
-          "items": {
-            "type": "string"
-          }
-        },
         "choice": {
           "type": "array",
           "items": {
diff --git a/templates/definition/defaults-schema.json b/templates/definition/defaults-schema.json
index a444cfd9..aede6d99 100644
--- a/templates/definition/defaults-schema.json
+++ b/templates/definition/defaults-schema.json
@@ -50,6 +50,7 @@
       },
       "required": [
         "generic",
+        "heating",
         "sockets",
         "switchsockets",
         "scooter"
diff --git a/templates/definition/devices-schema.json b/templates/definition/devices-schema.json
index 2d6f47c7..efedc8b1 100644
--- a/templates/definition/devices-schema.json
+++ b/templates/definition/devices-schema.json
@@ -65,7 +65,9 @@
           "type": "string"
         }
       },
-      "required": ["name"],
+      "required": [
+        "name"
+      ],
       "title": "Template"
     },
     "Product": {
@@ -80,14 +82,28 @@
         }
       },
       "anyOf": [
-        {"required": ["brand"]},
-        {"required": ["description"]}
+        {
+          "required": [
+            "brand"
+          ]
+        },
+        {
+          "required": [
+            "description"
+          ]
+        }
       ],
       "title": "Product"
     },
     "Groups": {
       "type": "string",
-      "enum": ["generic", "switchsockets", "scooter", "sockets"]
+      "enum": [
+        "generic",
+        "heating",
+        "switchsockets",
+        "scooter",
+        "sockets"
+      ]
     },
     "Linked": {
       "type": "object",
@@ -99,7 +115,11 @@
         },
         "usage": {
           "type": "string",
-          "enum": ["grid", "pv", "battery"]
+          "enum": [
+            "grid",
+            "pv",
+            "battery"
+          ]
         },
         "multiple": {
           "type": "boolean"
@@ -109,11 +129,21 @@
           "minLength": 1
         }
       },
-      "required": ["template", "usage"]
+      "required": [
+        "template",
+        "usage"
+      ]
     },
     "Capability": {
       "type": "string",
-      "enum": ["1p3p", "iso151182", "rfid", "smahems", "mA"]
+      "enum": [
+        "1p3p",
+        "battery-control",
+        "iso151182",
+        "rfid",
+        "smahems",
+        "mA"
+      ]
     }
   }
 }
\ No newline at end of file
diff --git a/templates/definition/meter/enphase.yaml b/templates/definition/meter/enphase.yaml
index 581e4c67..7d22b15a 100644
--- a/templates/definition/meter/enphase.yaml
+++ b/templates/definition/meter/enphase.yaml
@@ -30,6 +30,34 @@ render: |
     insecure: true
     {{- end }}
     jq: .consumption[] | select(.measurementType == "net-consumption").wNow
+  currents:
+    - source: http
+      uri: http://{{ .host }}/production.json?details=1
+      {{- if .token }}
+      auth:
+        type: bearer
+        password: {{ .token }}
+      insecure: true
+      {{- end }}
+      jq: if .consumption[] | select(.measurementType == "net-consumption").activeCount >= 1 then .consumption[] | select(.measurementType == "net-consumption").lines[0].rmsCurrent else 0 end
+    - source: http
+      uri: http://{{ .host }}/production.json?details=1
+      {{- if .token }}
+      auth:
+        type: bearer
+        password: {{ .token }}
+      insecure: true
+      {{- end }}
+      jq: if .consumption[] | select(.measurementType == "net-consumption").activeCount >= 1 then .consumption[] | select(.measurementType == "net-consumption").lines[1].rmsCurrent else 0 end
+    - source: http
+      uri: http://{{ .host }}/production.json?details=1
+      {{- if .token }}
+      auth:
+        type: bearer
+        password: {{ .token }}
+      insecure: true
+      {{- end }}
+      jq: if .consumption[] | select(.measurementType == "net-consumption").activeCount >= 1 then .consumption[] | select(.measurementType == "net-consumption").lines[2].rmsCurrent else 0 end
   {{- end }}
   {{- if eq .usage "pv" }}
   power:
@@ -53,6 +81,34 @@ render: |
     {{- end }}
     jq: if (.production | length) > 1 and (.production[] | select(.measurementType == "production").activeCount >= 1) then .production[] | select(.measurementType == "production").whLifetime else .production[] | select(.type == "inverters").whLifetime end
     scale: 0.001
+  currents:
+    - source: http
+      uri: http://{{ .host }}/production.json?details=1
+      {{- if .token }}
+      auth:
+        type: bearer
+        password: {{ .token }}
+      insecure: true
+      {{- end }}
+      jq: if .production[] | select(.measurementType == "production").activeCount >= 1 then .production[] | select(.measurementType == "production").lines[0].rmsCurrent else 0 end
+    - source: http
+      uri: http://{{ .host }}/production.json?details=1
+      {{- if .token }}
+      auth:
+        type: bearer
+        password: {{ .token }}
+      insecure: true
+      {{- end }}
+      jq: if .production[] | select(.measurementType == "production").activeCount >= 1 then .production[] | select(.measurementType == "production").lines[1].rmsCurrent else 0 end
+    - source: http
+      uri: http://{{ .host }}/production.json?details=1
+      {{- if .token }}
+      auth:
+        type: bearer
+        password: {{ .token }}
+      insecure: true
+      {{- end }}
+      jq: if .production[] | select(.measurementType == "production").activeCount >= 1 then .production[] | select(.measurementType == "production").lines[2].rmsCurrent else 0 end
   {{- end }}
   {{- if eq .usage "battery" }}
   power:
diff --git a/templates/definition/meter/saj-h2.yaml b/templates/definition/meter/saj-h2.yaml
index 1c6d3d97..7bf665db 100644
--- a/templates/definition/meter/saj-h2.yaml
+++ b/templates/definition/meter/saj-h2.yaml
@@ -34,7 +34,7 @@ render: |
     source: modbus
     {{- include "modbus" . | indent 2 }}
     register:
-      address: 0x40A7 # TotalGridPowerWatt
+      address: 0x40AD # SysGridPowerWall
       type: holding
       decode: int16
   currents:
diff --git a/templates/definition/meter/saj-r5.yaml b/templates/definition/meter/saj-r5.yaml
new file mode 100644
index 00000000..c434a851
--- /dev/null
+++ b/templates/definition/meter/saj-r5.yaml
@@ -0,0 +1,31 @@
+template: saj-r5
+products:
+  - brand: SAJ
+    description:
+      generic: R5 Series Solar Inverter
+params:
+  - name: usage
+    choice: ["pv"]
+  - name: modbus
+    choice: ["rs485"]
+    baudrate: 9600
+    comset: 8N1
+render: |
+  type: custom
+  {{- if eq .usage "pv" }}
+  power:
+    source: modbus
+    {{- include "modbus" . | indent 2 }}
+    register:
+      address: 275 # 0x0113 Active power of inverter total output
+      type: holding
+      decode: uint16
+  energy:
+    source: modbus
+    {{- include "modbus" . | indent 2 }}
+    register:
+      address: 305 # 0x0131 Total Energy
+      type: holding
+      decode: uint32
+    scale: 0.01
+  {{- end }}
diff --git a/templates/definition/meter/sonnenbatterie.yaml b/templates/definition/meter/sonnenbatterie.yaml
index 3e414f97..b0322d5c 100644
--- a/templates/definition/meter/sonnenbatterie.yaml
+++ b/templates/definition/meter/sonnenbatterie.yaml
@@ -9,9 +9,11 @@ requirements:
   description:
     de: |
       Für die aktive Batteriesteuerung muss über das Webinterface der sonnenBatterie (unter Software-Integration) das "JSON Write API" aktiviert und das dort generierte API-Token in der Batteriekonfiguration unter `token` eingetragen werden.
+      Als Betriebsart der sonnenBatterie werden die beiden Modi "Eigenverbrauch" (Standard) und "Time-of-use" unterstützt. Der Modus kann über den Parameter `defaultmode` an die Konfiguration der sonnenBatterie angepasst werden.
       Die Leistung für das Netzladen kann an die Wechselrichterleistung der sonnenBatterie über den Parameter `chargepower` angepasst werden.
     en: |
       For active battery control, the "JSON Write API" must be activated via the sonnenBatterie web interface (under Software-Integration) and the API token generated there must be entered in the battery configuration under `token`.
+      The two operating modes supported for the sonnenBatterie are “self-consumption” (default) and “time-of-use”. The mode can be adapted to the configuration of the sonnenBatterie via the 'defaultmode' parameter.
       The power for grid charging can be adapted to the inverter power of the sonnenBatterie via the `chargepower` parameter.
 params:
   - name: usage
@@ -28,6 +30,15 @@ params:
       de: API Token (benötigt für aktive Batteriesteuerung)
       en: API Token (required for active battery control)
     usages: ["battery"]
+  - name: defaultmode
+    type: choice
+    choice: ["self-consumption", "time-of-use"]
+    default: self-consumption
+    advanced: true
+    help:
+      de: Standardmodus für die aktive Batteriesteuerung (self-consumption oder time-of-use)
+      en: Default mode for active battery control (self-consumption or time-of-use)
+    usages: ["battery"]
   - name: chargepower
     default: 3300
     advanced: true
@@ -66,7 +77,11 @@ render: |
         headers:
         - content-type: application/json
         - Auth-Token: {{ .token }}
-        body: '{"EM_OperatingMode":"2"}'  # self consumption
+        {{- if eq .defaultmode "time-of-use" }}
+        body: '{"EM_OperatingMode":"10"}' # Time-of-use
+        {{- else }}
+        body: '{"EM_OperatingMode":"2"}'  # Self-consumption
+        {{- end }}
     - case: 2 # hold
       set:
         source: sequence
@@ -78,7 +93,7 @@ render: |
           headers:
           - content-type: application/json
           - Auth-Token: {{ .token }}
-          body: '{"EM_OperatingMode":"1"}'  # manual
+          body: '{"EM_OperatingMode":"1"}'  # Manual
         - source: http
           uri: http://{{ .host }}/api/v2/setpoint/discharge/0
           insecure: true
diff --git a/templates/definition/vehicle/bmw.yaml b/templates/definition/vehicle/bmw.yaml
index b4ac04d3..be57f390 100644
--- a/templates/definition/vehicle/bmw.yaml
+++ b/templates/definition/vehicle/bmw.yaml
@@ -4,9 +4,9 @@ products:
 requirements:
   description:
     de: |
-      Benötigt `hcaptcha` Token. Dieses muss einmalig unter https://bimmer-connected.readthedocs.io/en/latest/captcha/rest_of_world.html generiert werden. Das Token ist nur für kurze Zeit gültig. Bitte möglichst schnell nach Generierung in die Konfiguration kopieren und evcc starten.
+      Benötigt `hcaptcha` Token. Dieses muss einmalig unter [bimmer-connected.readthedocs.io](https://bimmer-connected.readthedocs.io/en/latest/captcha/rest_of_world.html) generiert werden. Das Token ist nur für kurze Zeit gültig. Bitte möglichst schnell nach Generierung in die Konfiguration kopieren und evcc starten.
     en: |
-      Requires `hcaptcha` token. This must be generated once at https://bimmer-connected.readthedocs.io/en/latest/captcha/rest_of_world.html. The token is only valid for a short time. Please copy it into the configuration and start evcc as soon as possible after generation.
+      Requires `hcaptcha` token. This must be generated once at [bimmer-connected.readthedocs.io](https://bimmer-connected.readthedocs.io/en/latest/captcha/rest_of_world.html). The token is only valid for a short time. Please copy it into the configuration and start evcc as soon as possible after generation.
 params:
   - preset: vehicle-base
   - name: vin
diff --git a/templates/definition/vehicle/fiat.yaml b/templates/definition/vehicle/fiat.yaml
index 66e7b0c9..f29f02ec 100644
--- a/templates/definition/vehicle/fiat.yaml
+++ b/templates/definition/vehicle/fiat.yaml
@@ -9,13 +9,13 @@ params:
   - name: pin
     mask: true
     help:
-      en: Required for evcc to refresh the SoC while charging.
-      de: Benötigt zur Aktualisierung des Ladestands während der Ladung.
+      en: Required for evcc to refresh the SoC while charging. When connected to TWC3, used to start/stop charging.
+      de: Benötigt zur Aktualisierung des Ladestands während der Ladung. Bei Nutzung mit TWC3 kann der Ladevorgang mittels PIN gestartet und gestoppt werden.
   - preset: vehicle-features
 render: |
   type: fiat
   {{ include "vehicle-base" . }}
   {{- if .pin }}
-  pin: {{ .pin }} # mandatory to deep refresh Soc
+  pin: {{ .pin }} # mandatory to deep refresh Soc & start/stop charge
   {{- end }}
   {{ include "vehicle-features" . }}
diff --git a/templates/definition/vehicle/mini.yaml b/templates/definition/vehicle/mini.yaml
index 56f28c65..8611f871 100644
--- a/templates/definition/vehicle/mini.yaml
+++ b/templates/definition/vehicle/mini.yaml
@@ -4,9 +4,9 @@ products:
 requirements:
   description:
     de: |
-      Benötigt `hcaptcha` Token. Dieses muss einmalig unter https://bimmer-connected.readthedocs.io/en/latest/captcha/rest_of_world.html generiert werden. Das Token ist nur für kurze Zeit gültig. Bitte möglichst schnell nach Generierung in die Konfiguration kopieren und evcc starten.
+      Benötigt `hcaptcha` Token. Dieses muss einmalig unter [bimmer-connected.readthedocs.io](https://bimmer-connected.readthedocs.io/en/latest/captcha/rest_of_world.html) generiert werden. Das Token ist nur für kurze Zeit gültig. Bitte möglichst schnell nach Generierung in die Konfiguration kopieren und evcc starten.
     en: |
-      Requires `hcaptcha` token. This must be generated once at https://bimmer-connected.readthedocs.io/en/latest/captcha/rest_of_world.html. The token is only valid for a short time. Please copy it into the configuration and start evcc as soon as possible after generation.
+      Requires `hcaptcha` token. This must be generated once at [bimmer-connected.readthedocs.io](https://bimmer-connected.readthedocs.io/en/latest/captcha/rest_of_world.html). The token is only valid for a short time. Please copy it into the configuration and start evcc as soon as possible after generation.
 params:
   - preset: vehicle-base
   - name: vin
diff --git a/templates/definition/vehicle/smart.yaml b/templates/definition/vehicle/smart.yaml
index fb8d12e2..dd6b6613 100644
--- a/templates/definition/vehicle/smart.yaml
+++ b/templates/definition/vehicle/smart.yaml
@@ -3,8 +3,36 @@ products:
   - brand: Smart
     description:
       generic: EQ
+requirements:
+  description:
+    de: |
+      Benötigt `access` und `refresh` Tokens. Diese können über den Befehl `evcc token [name]` generiert werden.
+    en: |
+      Requires `access` and `refresh` tokens. These can be generated with command `evcc token [name]`.
 params:
-  - preset: vehicle-base
+  - preset: vehicle-common
+  - name: user
+    required: true
+  - name: region
+    required: true
+    choice: [EMEA, APAC, NORAM]
+    default: EMEA
+  - name: accessToken
+    required: true
+    mask: true
+  - name: refreshToken
+    required: true
+    mask: true
+  - name: vin
+    example: V...
+  - name: cache
+    default: 15m
 render: |
-  type: smart
-  {{ include "vehicle-base" . }}
+  type: smart-eq
+  vin: {{ .vin }}
+  user: {{ .user }}
+  region: {{ .region }}
+  tokens:
+    access: {{ .accessToken }}
+    refresh: {{ .refreshToken }}
+  {{ include "vehicle-common" . }}
diff --git a/templates/definition/vehicle/tesla.yaml b/templates/definition/vehicle/tesla.yaml
index 178053a8..7c2bc7c2 100644
--- a/templates/definition/vehicle/tesla.yaml
+++ b/templates/definition/vehicle/tesla.yaml
@@ -5,22 +5,39 @@ products:
 requirements:
   description:
     de: |
-      Benötigt `access` und `refresh` Tokens. Diese können über [tesla.evcc.io](https://tesla.evcc.io) erstellt werden.
-      Die Steuerung von Fahrzeugen im Zusammenspiel mit einem Tesla Wall Connector erfolgt über einen evcc Proxy-Server und benötigt ein Sponsor Token. Der virtuelle evcc Schlüssel muss auf dem Fahrzeug installiert sein.
-      Siehe [tesla.evcc.io](https://tesla.evcc.io).
+      Tesla bietet eine offizielle, aber kostenpflichtige Fahrzeug-API an.
+      Für private Nutzung kannst du dir einen [Tesla Developer Account](https://developer.tesla.com/) erstellen und erhältst ein monatliches API-Guthaben von $10. Das ist für die gängigen evcc-Anwendungsfälle in der Regel ausreichend.
+
+      Die Anleitung von [myteslamate.com](https://www.myteslamate.com/tesla-api-application-registration/) erklärt den Prozess und generiert dir kostenfrei die für evcc benötigten Access- und Refresh-Token.
+      Mit diesem Tokenpaar und deiner im Tesla Developer Account erstellten Client ID kann evcc direkt mit der Tesla API kommunizieren. Dein verbrauchtes Guthaben kannst du bei Tesla einsehen.
+
+      Für die Nutzung des "Tesla Wall Connectors" benötigst du einen öffentlichen Command-Proxy-Server. [myteslamate.com](https://app.myteslamate.com/) stellt diesen Dienst für 12€/Jahr bereit. Konfiguriere dafür bei myteslamate.com die Command-Berechtigungen und trage das Proxy-Token hier ein. Start-, Stopp- und Stromstärken-Kommandos werden über diesen Proxy an Tesla geschickt.
     en: |
-      Tesla `access` and `refresh` tokens are required. These can be generated through [tesla.evcc.io](https://tesla.evcc.io).
-      Controlling vehicles in conjunction with a Tesla Wall Connector is done via an evcc proxy server and requires a sponsor token. The evcc virtual key must be installed on the vehicle.
-      See [tesla.evcc.io](https://tesla.evcc.io).
+      Tesla offers an official, but paid vehicle API.
+      For private use, you can create a [Tesla Developer Account](https://developer.tesla.com/) and receive a monthly API credit of $10. This is usually sufficient for the common evcc use cases.
+
+      The [myteslamate.com](https://www.myteslamate.com/tesla-api-application-registration/) guide explains the process and generates a free Access and Refresh Token. With this token pair and your Client ID created in the Tesla Developer Account, evcc can directly communicate with the Tesla API. You can see your used credit at Tesla.
+
+      To use a Tesla Wall Connector, you need a public Command Proxy Server. [myteslamate.com](https://app.myteslamate.com/) provides this service for 12€/year. Configure the Command permissions at myteslamate.com and enter the Proxy Token here. Start, stopp and current commands are sent to Tesla via this proxy.
   evcc: ["sponsorship"]
 params:
   - preset: vehicle-common
   - name: clientId
+    help:
+      en: Client ID of your [Tesla Developer App](https://developer.tesla.com/dashboard).
+      de: Kunden ID deiner [Tesla Developer App](https://developer.tesla.com/dashboard).
+    required: true
     advanced: true
   - name: accessToken
+    help:
+      en: From [myteslamate.com](https://app.myteslamate.com/).
+      de: Von [myteslamate.com](https://app.myteslamate.com/).
     required: true
     mask: true
   - name: refreshToken
+    help:
+      en: From [myteslamate.com](https://app.myteslamate.com/).
+      de: Von [myteslamate.com](https://app.myteslamate.com/).
     required: true
     mask: true
   - name: vin
@@ -28,11 +45,16 @@ params:
   - name: control
     deprecated: true
   - name: commandProxy
-    default: https://tesla.evcc.io/
+    default: https://api.myteslamate.com
+    advanced: true
+    help:
+      en: "When using a TWC3 (or other 'dumb' charger not capable of control), evcc can manage the charge directly by communicating with the vehicle through a Command Proxy. By default the [myteslamate.com](https://app.myteslamate.com/) proxy is used. With this parameter, you set the base URL of a custom Command Proxy. See for example [TeslaBleHttpProxy](https://github.com/wimaha/TeslaBleHttpProxy) for a proxy sending commands via bluetooth."
+      de: "Bei Verwendung eines TWC3 (oder eines anderen 'dummen' Ladegeräts, das nicht steuerbar ist) kann evcc die Ladung direkt verwalten, indem es über einen Command Proxy mit dem Fahrzeug kommuniziert. Standardmäßig wird der [myteslamate.com](https://app.myteslamate.com/) Proxy verwendet. Mit diesem Parameter kannst du die Basis-URL ändern. Siehe zum Beispiel [TeslaBleHttpProxy](https://github.com/wimaha/TeslaBleHttpProxy) für einen Proxy, der Kommandos über Bluetooth sendet."
+  - name: proxyToken
     advanced: true
     help:
-      en: "When using a TWC3 (or other 'dumb' charger not capable of control), evcc can manage the charge directly by communicating with the vehicle through a Command Proxy. By default, the proxy provided by evcc is used. With this parameter, you set the base URL of a custom Command Proxy to use instead of the default evcc one. See for example https://github.com/wimaha/TeslaBleHttpProxy for a proxy sending commands via bluetooth."
-      de: "Bei Verwendung eines TWC3 (oder eines anderen 'dummen' Ladegeräts, das nicht steuerbar ist) kann evcc die Ladung direkt verwalten, indem es über einen Command Proxy mit dem Fahrzeug kommuniziert. Standardmäßig wird der von evcc bereitgestellte Proxy verwendet. Dieses parameter setzt die Basis-URL eines benutzerdefinierten Command Proxy, der anstelle des standardmäßigen evcc-Proxy verwendet werden soll. Siehe zum Beispiel https://github.com/wimaha/TeslaBleHttpProxy für einen Proxy, der Kommandos über Bluetooth sendet."
+      en: Token for the [myteslamate.com](https://app.myteslamate.com/) command proxy (12€/year fee). Ensure, that you've installed their Virtual Key and granted 'Charge Start', 'Charge Stop' and 'Set Charging Amps' permissions.
+      de: Token für den Command Proxy von [myteslamate.com](https://app.myteslamate.com/) (12€/Jahr). Stelle sicher, dass du den Virtual Key installiert hast und die Berechtigungen 'Ladung starten', 'Ladung stoppen' und 'Ladestrom setzen' erteilt hast.
   - name: cache
     default: 15m
 render: |
@@ -44,6 +66,7 @@ render: |
     access: {{ .accessToken }}
     refresh: {{ .refreshToken }}
   commandProxy: {{ .commandProxy }}
+  proxyToken: {{ .proxyToken }}
   {{ include "vehicle-common" . }}
   features: ["coarsecurrent"]
   cache: {{ .cache }}
diff --git a/util/logstash/log.go b/util/logstash/log.go
index 15450932..717fbbc5 100644
--- a/util/logstash/log.go
+++ b/util/logstash/log.go
@@ -65,7 +65,7 @@ func (l *logger) Size() int64 {
 	r := l.data
 	var size int64
 
-	for i := 0; i < r.Len(); i++ {
+	for range r.Len() {
 		if e, ok := r.Value.(element); ok {
 			size += int64(len(e))
 		}
diff --git a/util/templates/defaults.yaml b/util/templates/defaults.yaml
index 1db8a105..ba9ecb5e 100644
--- a/util/templates/defaults.yaml
+++ b/util/templates/defaults.yaml
@@ -238,23 +238,40 @@ params:
       - bus
       - moped
       - motorcycle
+      - rocket
       - scooter
+      - taxi
       - tractor
+      - rickshaw
       - shuttle
       - van
-      - heater
-      - heatpump
-      - waterheater
-      - laundry
-      - dishwasher
       - airpurifier
+      - battery
+      - bulb
       - climate
+      - coffeemaker
+      - compute
+      - cooking
       - cooler
-      - generic
+      - desktop
       - device
+      - dishwasher
+      - dryer
+      - floorlamp
+      - generic
+      - heater
+      - heatexchange
+      - heatpump
+      - kettle
+      - laundry
+      - laundry2
+      - machine
       - meter
-      - compute
+      - microwave
+      - pump
       - tool
+      - waterheater
+
   - name: ski
     required: true
     description:
diff --git a/util/time.go b/util/time.go
index 29296508..d1463fbd 100644
--- a/util/time.go
+++ b/util/time.go
@@ -32,7 +32,7 @@ func GetNextOccurrence(weekdays []int, timeStr string, tz string) (time.Time, er
 	}
 
 	// Check the next 7 days for a valid match
-	for i := 0; i < 7; i++ {
+	for range 7 {
 		weekday := int(target.Weekday())
 		if contains(weekdays, weekday) {
 			return target, nil
diff --git a/vehicle/bmw/provider.go b/vehicle/bmw/provider.go
index 6978347b..3c65beee 100644
--- a/vehicle/bmw/provider.go
+++ b/vehicle/bmw/provider.go
@@ -108,6 +108,14 @@ func (v *Provider) GetLimitSoc() (int64, error) {
 	return res.State.ElectricChargingState.ChargingTarget, nil
 }
 
+var _ api.VehicleClimater = (*Provider)(nil)
+
+// Climater implements the api.VehicleClimater interface
+func (v *Provider) Climater() (bool, error) {
+	res, err := v.statusG()
+	return res.State.ClimateControlState.Activity == "HEATING" || res.State.ClimateControlState.Activity == "COOLING", err
+}
+
 var _ api.Resurrector = (*Provider)(nil)
 
 func (v *Provider) WakeUp() error {
diff --git a/vehicle/bmw/types.go b/vehicle/bmw/types.go
index 6d2f7aa7..5f8f07ec 100644
--- a/vehicle/bmw/types.go
+++ b/vehicle/bmw/types.go
@@ -19,5 +19,8 @@ type VehicleStatus struct {
 			ChargingStatus       string
 			ChargingTarget       int64
 		}
+		ClimateControlState struct {
+			Activity string
+		}
 	}
 }
diff --git a/vehicle/fiat.go b/vehicle/fiat.go
index c219069c..dc51cf9e 100644
--- a/vehicle/fiat.go
+++ b/vehicle/fiat.go
@@ -15,6 +15,7 @@ import (
 type Fiat struct {
 	*embed
 	*fiat.Provider
+	*fiat.Controller
 }
 
 func init() {
@@ -59,6 +60,7 @@ func NewFiatFromConfig(other map[string]interface{}) (api.Vehicle, error) {
 
 	if err == nil {
 		v.Provider = fiat.NewProvider(api, cc.VIN, cc.PIN, cc.Expiry, cc.Cache)
+		v.Controller = fiat.NewController(v.Provider, api, cc.VIN, cc.PIN)
 	}
 
 	return v, err
diff --git a/vehicle/fiat/api.go b/vehicle/fiat/api.go
index ef0e0dd4..76e78df7 100644
--- a/vehicle/fiat/api.go
+++ b/vehicle/fiat/api.go
@@ -158,3 +158,35 @@ func (v *API) Action(vin, pin, action, cmd string) (ActionResponse, error) {
 
 	return res, err
 }
+
+func (v *API) UpdateSchedule(vin, pin string, schedules []Schedule) (ActionResponse, error) {
+	var res ActionResponse
+
+	token, err := v.pinAuth(pin)
+	if err != nil {
+		return res, err
+	}
+
+	uri := fmt.Sprintf("%s/v2/accounts/%s/vehicles/%s/ev/schedule", ApiURI, v.identity.UID(), vin)
+
+	data := struct {
+		Command   string     `json:"command"`
+		PinAuth   string     `json:"pinAuth"`
+		Schedules []Schedule `json:"schedules"`
+	}{
+		Command:   "CPPLUS",
+		PinAuth:   token,
+		Schedules: schedules,
+	}
+
+	req, err := v.request(http.MethodPost, uri, request.MarshalJSON(data))
+	if err == nil {
+		err = v.DoJSON(req, &res)
+	}
+
+	if err == nil && res.Message != "" {
+		err = fmt.Errorf("action schedules: %s", res.Message)
+	}
+
+	return res, err
+}
diff --git a/vehicle/fiat/controller.go b/vehicle/fiat/controller.go
new file mode 100644
index 00000000..71b47e80
--- /dev/null
+++ b/vehicle/fiat/controller.go
@@ -0,0 +1,102 @@
+package fiat
+
+import (
+	"fmt"
+	"time"
+
+	"github.com/evcc-io/evcc/api"
+)
+
+type Controller struct {
+	pvd *Provider
+	api *API
+	vin string
+	pin string
+}
+
+// NewController creates a vehicle current and charge controller
+func NewController(provider *Provider, api *API, vin string, pin string) *Controller {
+	impl := &Controller{
+		pvd: provider,
+		api: api,
+		vin: vin,
+		pin: pin,
+	}
+	return impl
+}
+
+var _ api.CurrentController = (*Controller)(nil)
+
+// MaxCurrent implements the api.CurrentController interface
+func (c *Controller) MaxCurrent(current int64) error {
+	// Even if we cannot control the current, this interface must be implemented otherwise the ChargeEnable is never called
+	return nil
+}
+
+var _ api.ChargeController = (*Controller)(nil)
+
+// ChargeEnable implements the api.ChargeController interface
+func (c *Controller) ChargeEnable(enable bool) error {
+	if c.pin == "" {
+		return api.ErrMissingCredentials
+	}
+
+	// get current schedule status from provider (cached)
+	stat, err := c.pvd.statusG()
+	if err != nil {
+		return err
+	}
+	if stat.EvInfo == nil || stat.EvInfo.Schedules == nil || len(stat.EvInfo.Schedules) == 0 {
+		return api.ErrNotAvailable
+	}
+
+	// configure first schedule and make sure it's active
+	c.configureChargeSchedule(&stat.EvInfo.Schedules[0])
+
+	if enable {
+		// start charging by updating active charge schedule to start now and end in 12h
+		stat.EvInfo.Schedules[0].StartTime = time.Now().Format("15:04")                   // only hour and minutes
+		stat.EvInfo.Schedules[0].EndTime = time.Now().Add(time.Hour * 12).Format("15:04") // only hour and minutes
+	} else {
+		// stop charging by updating active charge schedule end time to now
+		stat.EvInfo.Schedules[0].EndTime = time.Now().Format("15:04") // only hour and minutes
+	}
+
+	// make sure the other charge schedules are disabled in case user changed them
+	c.disableConflictingChargeSchedule(&stat.EvInfo.Schedules[1])
+	c.disableConflictingChargeSchedule(&stat.EvInfo.Schedules[2])
+
+	// post new schedule
+	res, err := c.api.UpdateSchedule(c.vin, c.pin, stat.EvInfo.Schedules)
+	if err == nil && res.ResponseStatus != "pending" {
+		err = fmt.Errorf("invalid response status: %s", res.ResponseStatus)
+	}
+
+	return err
+}
+
+func (c *Controller) configureChargeSchedule(schedule *Schedule) {
+	// all values are set to be sure no manual change can lead to inconsistencies
+	schedule.CabinPriority = false
+	schedule.ChargeToFull = false
+	schedule.EnableScheduleType = true
+	schedule.RepeatSchedule = true
+	schedule.ScheduleType = "CHARGE"
+
+	// only enable for current day to avoid undesired charge start in the future
+	weekday := time.Now().Weekday()
+	schedule.ScheduledDays.Monday = (weekday == time.Monday)
+	schedule.ScheduledDays.Tuesday = (weekday == time.Tuesday)
+	schedule.ScheduledDays.Wednesday = (weekday == time.Wednesday)
+	schedule.ScheduledDays.Thursday = (weekday == time.Thursday)
+	schedule.ScheduledDays.Friday = (weekday == time.Friday)
+	schedule.ScheduledDays.Saturday = (weekday == time.Saturday)
+	schedule.ScheduledDays.Sunday = (weekday == time.Sunday)
+}
+
+func (c *Controller) disableConflictingChargeSchedule(schedule *Schedule) {
+	// make sure the other charge schedules are disabled in case user changed them
+	if schedule.ScheduleType == "CHARGE" && schedule.EnableScheduleType {
+		schedule.EnableScheduleType = false
+	}
+}
diff --git a/vehicle/fiat/types.go b/vehicle/fiat/types.go
index c175fba0..babb2467 100644
--- a/vehicle/fiat/types.go
+++ b/vehicle/fiat/types.go
@@ -28,6 +28,25 @@ type Vehicle struct {
 	VIN string
 }
 
+type Schedule struct {
+	CabinPriority      bool   `json:"cabinPriority"`
+	ChargeToFull       bool   `json:"chargeToFull"`
+	EnableScheduleType bool   `json:"enableScheduleType"`
+	EndTime            string `json:"endTime"`
+	RepeatSchedule     bool   `json:"repeatSchedule"`
+	ScheduleType       string `json:"scheduleType"`
+	ScheduledDays      struct {
+		Friday    bool `json:"friday"`
+		Monday    bool `json:"monday"`
+		Saturday  bool `json:"saturday"`
+		Sunday    bool `json:"sunday"`
+		Thursday  bool `json:"thursday"`
+		Tuesday   bool `json:"tuesday"`
+		Wednesday bool `json:"wednesday"`
+	} `json:"scheduledDays"`
+	StartTime string `json:"startTime"`
+}
+
 type StatusResponse struct {
 	VehicleInfo struct {
 		Odometer struct {
@@ -52,6 +71,7 @@ type StatusResponse struct {
 			TimeToFullyChargeL2 int     // 540
 			TotalRange          int     // 17
 		}
+		Schedules []Schedule
 		Timestamp TimeMillis
 	} `json:",omitempty"`
 	Timestamp TimeMillis
diff --git a/vehicle/mercedes.go b/vehicle/mercedes.go
index cfb461ae..9eeab3f7 100644
--- a/vehicle/mercedes.go
+++ b/vehicle/mercedes.go
@@ -1,6 +1,7 @@
 package vehicle
 
 import (
+	"errors"
 	"time"
 
 	"github.com/evcc-io/evcc/api"
@@ -15,11 +16,16 @@ type Mercedes struct {
 }
 
 func init() {
-	registry.Add("mercedes", NewMercedesFromConfig)
+	registry.Add("mercedes", func(other map[string]interface{}) (api.Vehicle, error) {
+		return newMercedesFromConfig("mercedes", other)
+	})
+	registry.Add("smart-eq", func(other map[string]interface{}) (api.Vehicle, error) {
+		return newMercedesFromConfig("smart-eq", other)
+	})
 }
 
-// NewMercedesFromConfig creates a new vehicle
-func NewMercedesFromConfig(other map[string]interface{}) (api.Vehicle, error) {
+// newMercedesFromConfig creates a new vehicle
+func newMercedesFromConfig(brand string, other map[string]interface{}) (api.Vehicle, error) {
 	cc := struct {
 		embed    `mapstructure:",squash"`
 		Tokens   Tokens
@@ -45,21 +51,28 @@ func NewMercedesFromConfig(other map[string]interface{}) (api.Vehicle, error) {
 		cc.User = cc.Account_
 	}
 
-	log := util.NewLogger("mercedes").Redact(cc.Tokens.Access, cc.Tokens.Refresh)
+	log := util.NewLogger(brand).Redact(cc.Tokens.Access, cc.Tokens.Refresh)
 	identity, err := mercedes.NewIdentity(log, token, cc.User, cc.Region)
 	if err != nil {
 		return nil, err
 	}
 
-	v := &Mercedes{
-		embed: &cc.embed,
-	}
-
 	api := mercedes.NewAPI(log, identity)
 
-	cc.VIN, err = ensureVehicle(cc.VIN, api.Vehicles)
-	if err == nil {
-		v.Provider = mercedes.NewProvider(api, cc.VIN, cc.Cache)
+	if brand == "smart-eq" {
+		if cc.VIN == "" {
+			return nil, errors.New("missing VIN")
+		}
+	} else {
+		cc.VIN, err = ensureVehicle(cc.VIN, api.Vehicles)
+		if err != nil {
+			return nil, err
+		}
+	}
+
+	v := &Mercedes{
+		embed:    &cc.embed,
+		Provider: mercedes.NewProvider(api, cc.VIN, cc.Cache),
 	}
 
 	return v, err
diff --git a/vehicle/mercedes/api.go b/vehicle/mercedes/api.go
index 47acfb02..4a87d655 100644
--- a/vehicle/mercedes/api.go
+++ b/vehicle/mercedes/api.go
@@ -114,5 +114,15 @@ func (v *API) Status(vin string) (StatusResponse, error) {
 		}
 	}
 
+	// There are two attributes for the proconditioning status, precondNow and precondActive
+	if val, ok := message.Attributes["precondNow"]; ok {
+		res.Preconditioning.Active = val.GetBoolValue()
+	}
+	if !res.Preconditioning.Active {
+		if val, ok := message.Attributes["precondActive"]; ok {
+			res.Preconditioning.Active = val.GetBoolValue()
+		}
+	}
+
 	return res, err
 }
diff --git a/vehicle/mercedes/provider.go b/vehicle/mercedes/provider.go
index babbb57d..2898d2a0 100644
--- a/vehicle/mercedes/provider.go
+++ b/vehicle/mercedes/provider.go
@@ -64,6 +64,14 @@ func (v *Provider) Status() (api.ChargeStatus, error) {
 	return status, err
 }
 
+var _ api.VehicleClimater = (*Provider)(nil)
+
+// Climater implements the api.VehicleClimater interface
+func (v *Provider) Climater() (bool, error) {
+	res, err := v.dataG()
+	return res.Preconditioning.Active, err
+}
+
 var _ api.VehiclePosition = (*Provider)(nil)
 
 // Position implements the api.VehiclePosition interface
diff --git a/vehicle/mercedes/types.go b/vehicle/mercedes/types.go
index 98278087..1875d88e 100644
--- a/vehicle/mercedes/types.go
+++ b/vehicle/mercedes/types.go
@@ -73,5 +73,9 @@ type StatusResponse struct {
 		Longitude float64
 		Latitude  float64
 	}
+	Preconditioning struct {
+		Active    bool
+		Timestamp time.Time
+	}
 	Timestamp time.Time
 }
diff --git a/vehicle/niu/types.go b/vehicle/niu/types.go
index cf131470..e4729d4c 100644
--- a/vehicle/niu/types.go
+++ b/vehicle/niu/types.go
@@ -5,6 +5,7 @@ import (
 	"errors"
 	"time"
 
+	"github.com/evcc-io/evcc/api"
 	"golang.org/x/oauth2"
 )
 
@@ -35,7 +36,7 @@ func (t *Token) UnmarshalJSON(data []byte) error {
 			if msg := res.Data.Desc; msg != "" {
 				return errors.New(msg)
 			}
-			return errors.New("missing token")
+			return api.ErrMissingToken
 		}
 
 		(*t) = (Token)(res.Data.Token.Token)
diff --git a/vehicle/saic/api.go b/vehicle/saic/api.go
index 311000f4..c6f418d1 100644
--- a/vehicle/saic/api.go
+++ b/vehicle/saic/api.go
@@ -103,6 +103,11 @@ func (v *API) repeatRequest(path string, event_id string) {
 		count++
 	}
 
+	// Make sure that we don't exit here with status running (probably after 20 tries).
+	// This would not allow us to ever do a query again.
+	if v.request.Status == StatRunning {
+		v.request.Status = StatInvalid
+	}
 	v.Logger.DEBUG.Printf("Exiting repeated query. Count: %d\n", count)
 }
 
diff --git a/vehicle/smart.go b/vehicle/smart.go
deleted file mode 100644
index 15eecb99..00000000
--- a/vehicle/smart.go
+++ /dev/null
@@ -1,65 +0,0 @@
-package vehicle
-
-import (
-	"fmt"
-	"time"
-
-	"github.com/evcc-io/evcc/api"
-	"github.com/evcc-io/evcc/util"
-	"github.com/evcc-io/evcc/vehicle/mb"
-	"github.com/evcc-io/evcc/vehicle/smart"
-)
-
-// Smart is an api.Vehicle implementation for Smart cars
-type Smart struct {
-	*embed
-	*smart.Provider
-}
-
-func init() {
-	registry.Add("smart", NewSmartFromConfig)
-}
-
-// NewSmartFromConfig creates a new vehicle
-func NewSmartFromConfig(other map[string]interface{}) (api.Vehicle, error) {
-	cc := struct {
-		embed          `mapstructure:",squash"`
-		User, Password string
-		VIN            string
-		Expiry         time.Duration
-		Cache          time.Duration
-	}{
-		Expiry: expiry,
-		Cache:  interval,
-	}
-
-	if err := util.DecodeOther(other, &cc); err != nil {
-		return nil, err
-	}
-
-	if cc.User == "" || cc.Password == "" {
-		return nil, api.ErrMissingCredentials
-	}
-
-	log := util.NewLogger("smart").Redact(cc.User, cc.Password, cc.VIN)
-
-	v := &Smart{
-		embed: &cc.embed,
-	}
-
-	identity := mb.NewIdentity(log, smart.OAuth2Config)
-	err := identity.Login(cc.User, cc.Password)
-	if err != nil {
-		return v, fmt.Errorf("login failed: %w", err)
-	}
-
-	api := smart.NewAPI(log, identity)
-
-	cc.VIN, err = ensureVehicle(cc.VIN, api.Vehicles)
-
-	if err == nil {
-		v.Provider = smart.NewProvider(log, api, cc.VIN, cc.Expiry, cc.Cache)
-	}
-
-	return v, err
-}
diff --git a/vehicle/tesla.go b/vehicle/tesla.go
index 4813ff51..1b652619 100644
--- a/vehicle/tesla.go
+++ b/vehicle/tesla.go
@@ -2,13 +2,12 @@ package vehicle
 
 import (
 	"context"
-	"os"
+	"errors"
 	"time"
 
 	"github.com/evcc-io/evcc/api"
 	"github.com/evcc-io/evcc/util"
 	"github.com/evcc-io/evcc/util/request"
-	"github.com/evcc-io/evcc/util/sponsor"
 	"github.com/evcc-io/evcc/util/transport"
 	"github.com/evcc-io/evcc/vehicle/tesla"
 	teslaclient "github.com/evcc-io/tesla-proxy-client"
@@ -23,15 +22,7 @@ type Tesla struct {
 }
 
 func init() {
-	if id := os.Getenv("TESLA_CLIENT_ID"); id != "" {
-		tesla.OAuth2Config.ClientID = id
-	}
-	if secret := os.Getenv("TESLA_CLIENT_SECRET"); secret != "" {
-		tesla.OAuth2Config.ClientSecret = secret
-	}
-	if tesla.OAuth2Config.ClientID != "" {
-		registry.Add("tesla", NewTeslaFromConfig)
-	}
+	registry.Add("tesla", NewTeslaFromConfig)
 }
 
 // NewTeslaFromConfig creates a new vehicle
@@ -42,6 +33,7 @@ func NewTeslaFromConfig(other map[string]interface{}) (api.Vehicle, error) {
 		Tokens       Tokens
 		VIN          string
 		CommandProxy string
+		ProxyToken   string
 		Cache        time.Duration
 		Timeout      time.Duration
 	}{
@@ -54,12 +46,8 @@ func NewTeslaFromConfig(other map[string]interface{}) (api.Vehicle, error) {
 		return nil, err
 	}
 
-	if cc.Credentials.ID != "" {
-		tesla.OAuth2Config.ClientID = cc.Credentials.ID
-	}
-
-	if cc.Credentials.Secret != "" {
-		tesla.OAuth2Config.ClientSecret = cc.Credentials.Secret
+	if cc.Credentials.ID == "" {
+		return nil, errors.New("missing client id, see https://github.com/evcc-io/evcc/discussions/17501")
 	}
 
 	token, err := cc.Tokens.Token()
@@ -68,19 +56,21 @@ func NewTeslaFromConfig(other map[string]interface{}) (api.Vehicle, error) {
 	}
 
 	log := util.NewLogger("tesla").Redact(
-		cc.Tokens.Access, cc.Tokens.Refresh,
-		tesla.OAuth2Config.ClientID, tesla.OAuth2Config.ClientSecret,
+		cc.Tokens.Access, cc.Tokens.Refresh, cc.ProxyToken,
+		cc.Credentials.ID, cc.Credentials.Secret,
 	)
 
-	identity, err := tesla.NewIdentity(log, token)
+	identity, err := tesla.NewIdentity(log, tesla.OAuth2Config(cc.Credentials.ID, cc.Credentials.Secret), token)
 	if err != nil {
 		return nil, err
 	}
 
 	hc := request.NewClient(log)
+	baseTransport := hc.Transport
+
 	hc.Transport = &oauth2.Transport{
 		Source: identity,
-		Base:   hc.Transport,
+		Base:   baseTransport,
 	}
 
 	tc, err := teslaclient.NewClient(context.Background(), teslaclient.WithClient(hc))
@@ -109,9 +99,9 @@ func NewTeslaFromConfig(other map[string]interface{}) (api.Vehicle, error) {
 	pc := request.NewClient(log)
 	pc.Transport = &transport.Decorator{
 		Decorator: transport.DecorateHeaders(map[string]string{
-			"X-Auth-Token": sponsor.Token,
+			"Authorization": "Bearer " + cc.ProxyToken,
 		}),
-		Base: hc.Transport,
+		Base: baseTransport,
 	}
 
 	tcc, err := teslaclient.NewClient(context.Background(), teslaclient.WithClient(pc))
diff --git a/vehicle/tesla/controller.go b/vehicle/tesla/controller.go
index e9d1dc56..300f635c 100644
--- a/vehicle/tesla/controller.go
+++ b/vehicle/tesla/controller.go
@@ -5,11 +5,10 @@ import (
 	"slices"
 
 	"github.com/evcc-io/evcc/api"
-	"github.com/evcc-io/evcc/util/sponsor"
 	"github.com/evcc-io/tesla-proxy-client"
 )
 
-const ProxyBaseUrl = "https://tesla.evcc.io"
+const ProxyBaseUrl = "https://api.myteslamate.com"
 
 type Controller struct {
 	vehicle *tesla.Vehicle
@@ -28,10 +27,6 @@ var _ api.CurrentController = (*Controller)(nil)
 
 // MaxCurrent implements the api.CurrentController interface
 func (v *Controller) MaxCurrent(current int64) error {
-	if !sponsor.IsAuthorized() {
-		return api.ErrSponsorRequired
-	}
-
 	return apiError(v.vehicle.SetChargingAmps(int(current)))
 }
 
@@ -39,10 +34,6 @@ var _ api.ChargeController = (*Controller)(nil)
 
 // ChargeEnable implements the api.ChargeController interface
 func (v *Controller) ChargeEnable(enable bool) error {
-	if !sponsor.IsAuthorized() {
-		return api.ErrSponsorRequired
-	}
-
 	var err error
 
 	if enable {
diff --git a/vehicle/tesla/identity.go b/vehicle/tesla/identity.go
index bc3295b1..8f49b431 100644
--- a/vehicle/tesla/identity.go
+++ b/vehicle/tesla/identity.go
@@ -17,35 +17,28 @@ import (
 // https://auth.tesla.com/oauth2/v3/.well-known/openid-configuration
 
 // OAuth2Config is the OAuth2 configuration for authenticating with the Tesla API.
-var OAuth2Config = &oauth2.Config{
-	RedirectURL: "https://auth.tesla.com/void/callback",
-	Endpoint: oauth2.Endpoint{
-		AuthURL:   "https://auth.tesla.com/en_us/oauth2/v3/authorize",
-		TokenURL:  "https://auth.tesla.com/oauth2/v3/token",
-		AuthStyle: oauth2.AuthStyleInParams,
-	},
-	Scopes: []string{"openid", "email", "offline_access"},
-}
-
-var TESLA_CLIENT_ID, TESLA_CLIENT_SECRET string
-
-func init() {
-	if TESLA_CLIENT_ID != "" {
-		OAuth2Config.ClientID = TESLA_CLIENT_ID
-	}
-	if TESLA_CLIENT_SECRET != "" {
-		OAuth2Config.ClientSecret = TESLA_CLIENT_SECRET
+func OAuth2Config(id, secret string) *oauth2.Config {
+	return &oauth2.Config{
+		ClientID:     id,
+		ClientSecret: secret,
+		RedirectURL:  "https://auth.tesla.com/void/callback",
+		Endpoint: oauth2.Endpoint{
+			AuthURL:   "https://auth.tesla.com/en_us/oauth2/v3/authorize",
+			TokenURL:  "https://auth.tesla.com/oauth2/v3/token",
+			AuthStyle: oauth2.AuthStyleInParams,
+		},
+		Scopes: []string{"openid", "email", "offline_access"},
 	}
 }
 
 type Identity struct {
 	oauth2.TokenSource
 	mu      sync.Mutex
-	log     *util.Logger
+	rts     oauth2.TokenSource
 	subject string
 }
 
-func NewIdentity(log *util.Logger, token *oauth2.Token) (oauth2.TokenSource, error) {
+func NewIdentity(log *util.Logger, oc *oauth2.Config, token *oauth2.Token) (oauth2.TokenSource, error) {
 	// serialise instance handling
 	mu.Lock()
 	defer mu.Unlock()
@@ -66,7 +59,6 @@ func NewIdentity(log *util.Logger, token *oauth2.Token) (oauth2.TokenSource, err
 	}
 
 	v := &Identity{
-		log:     log,
 		subject: claims.Subject,
 	}
 
@@ -90,6 +82,10 @@ func NewIdentity(log *util.Logger, token *oauth2.Token) (oauth2.TokenSource, err
 
 	v.TokenSource = oauth.RefreshTokenSource(token, v)
 
+	// refresh token source
+	ctx := context.WithValue(context.Background(), oauth2.HTTPClient, request.NewClient(log))
+	v.rts = oc.TokenSource(ctx, token)
+
 	// add instance
 	addInstance(claims.Subject, v)
 
@@ -104,10 +100,7 @@ func (v *Identity) RefreshToken(token *oauth2.Token) (*oauth2.Token, error) {
 	v.mu.Lock()
 	defer v.mu.Unlock()
 
-	ctx := context.WithValue(context.Background(), oauth2.HTTPClient, request.NewClient(v.log))
-	ts := OAuth2Config.TokenSource(ctx, token)
-
-	token, err := ts.Token()
+	token, err := v.rts.Token()
 	if err != nil {
 		return nil, err
 	}
diff --git a/vehicle/types.go b/vehicle/types.go
index c30b58bf..71799612 100644
--- a/vehicle/types.go
+++ b/vehicle/types.go
@@ -4,6 +4,7 @@ import (
 	"errors"
 	"time"
 
+	"github.com/evcc-io/evcc/api"
 	"golang.org/x/oauth2"
 )
 
@@ -15,11 +16,11 @@ type ClientCredentials struct {
 // Error validates the credentials and returns an error if they are incomplete
 func (c *ClientCredentials) Error() error {
 	if c.ID == "" {
-		return errors.New("missing credentials id")
+		return errors.New("missing client id")
 	}
 
 	if c.Secret == "" {
-		return errors.New("missing credentials secret")
+		return errors.New("missing client secret")
 	}
 
 	return nil
@@ -33,7 +34,7 @@ type Tokens struct {
 // Token builds token from credentials and returns an error if they are incomplete
 func (t *Tokens) Token() (*oauth2.Token, error) {
 	if t.Access == "" && t.Refresh == "" {
-		return nil, errors.New("missing access and/or refresh token, use `evcc token` to create")
+		return nil, api.ErrMissingToken
 	}
 
 	return &oauth2.Token{


Test patch:
diff --git a/charger/go-e_test.go b/charger/go-e_test.go
index 7274e0d0..bb9d3021 100644
--- a/charger/go-e_test.go
+++ b/charger/go-e_test.go
@@ -33,12 +33,11 @@ func (h *handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
 }
 
 func TestGoEV1(t *testing.T) {
-	h := &handler{}
-	srv := httptest.NewServer(h)
+	srv := httptest.NewServer(new(handler))
 
 	sponsor.Subject = "foo"
 
-	wb, err := NewGoE(srv.URL, "", 0)
+	wb, err := newGoEFromConfig(false, map[string]any{"uri": srv.URL})
 	if err != nil {
 		t.Error(err)
 	}
@@ -57,13 +56,12 @@ func TestGoEV1(t *testing.T) {
 }
 
 func TestGoEV2(t *testing.T) {
-	h := &handler{}
-	srv := httptest.NewServer(h)
+	srv := httptest.NewServer(new(handler))
+	srv.Config.Handler.(*handler).expect("/api/status?filter=alw")
 
 	sponsor.Subject = "foo"
 
-	h.expect("/api/status?filter=alw")
-	wb, err := NewGoE(srv.URL, "", 0)
+	wb, err := newGoEFromConfig(false, map[string]any{"uri": srv.URL})
 	if err != nil {
 		t.Error(err)
 	}
diff --git a/charger/template_test.go b/charger/template_test.go
index 15dbf950..9b0e6c38 100644
--- a/charger/template_test.go
+++ b/charger/template_test.go
@@ -4,11 +4,14 @@ import (
 	"context"
 	"testing"
 
+	"github.com/evcc-io/evcc/api"
 	"github.com/evcc-io/evcc/util/templates"
 	"github.com/evcc-io/evcc/util/test"
 )
 
 var acceptable = []string{
+	api.ErrMissingCredentials.Error(),
+	api.ErrMissingToken.Error(),
 	"invalid plugin source: ...",
 	"missing mqtt broker configuration",
 	"mqtt not configured",
@@ -29,7 +32,6 @@ var acceptable = []string{
 	"sponsorship required, see https://github.com/evcc-io/evcc#sponsorship",
 	"eebus not configured",
 	"context deadline exceeded",
-	"missing credentials",
 	"timeout",                    // ocpp
 	"must have uri and password", // Wattpilot
 }
diff --git a/core/planner/helper_test.go b/core/planner/helper_test.go
index 7e082e2d..2206e7a1 100644
--- a/core/planner/helper_test.go
+++ b/core/planner/helper_test.go
@@ -18,7 +18,7 @@ func TestSlotHasSuccessor(t *testing.T) {
 		plan[i], plan[j] = plan[j], plan[i]
 	})
 
-	for i := 0; i < len(plan); i++ {
+	for i := range plan {
 		if plan[i] != last {
 			require.True(t, SlotHasSuccessor(plan[i], plan))
 		}
diff --git a/meter/template_test.go b/meter/template_test.go
index 982572ab..0cdae033 100644
--- a/meter/template_test.go
+++ b/meter/template_test.go
@@ -4,18 +4,19 @@ import (
 	"context"
 	"testing"
 
+	"github.com/evcc-io/evcc/api"
 	"github.com/evcc-io/evcc/util/templates"
 	"github.com/evcc-io/evcc/util/test"
 )
 
 var acceptable = []string{
+	api.ErrMissingCredentials.Error(),
+	api.ErrMissingToken.Error(),
 	"invalid plugin source: ...",
 	"missing mqtt broker configuration",
-	"missing token",
 	"mqtt not configured",
 	"not a SunSpec device",
 	"connect: connection refused", // sockets
-	"missing credentials",         // sockets
 	"power: timeout",              // sockets
 	"missing password",            // Powerwall
 	"connect: no route to host",
diff --git a/meter/zendure/connection_test.go b/meter/zendure/connection_test.go
new file mode 100644
index 00000000..12eb9bfb
--- /dev/null
+++ b/meter/zendure/connection_test.go
@@ -0,0 +1,31 @@
+package zendure
+
+import (
+	"testing"
+	"time"
+
+	"github.com/evcc-io/evcc/util"
+	"github.com/stretchr/testify/assert"
+)
+
+func TestHandler(t *testing.T) {
+	conn := &Connection{
+		log:  util.NewLogger("test"),
+		data: util.NewMonitor[Data](time.Minute),
+	}
+
+	{
+		conn.handler(`{"solarInputPower":113,"sn":"serial"}`)
+		res, err := conn.data.Get()
+		assert.NoError(t, err)
+
+		assert.Equal(t, Data{SolarInputPower: 113, Sn: "serial"}, res)
+	}
+	{
+		conn.handler(`{"solarInputPower":125,"sn":"serial"}`)
+		res, err := conn.data.Get()
+		assert.NoError(t, err)
+
+		assert.Equal(t, Data{SolarInputPower: 125, Sn: "serial"}, res)
+	}
+}
diff --git a/provider/const_test.go b/provider/const_test.go
new file mode 100644
index 00000000..aff1e4cf
--- /dev/null
+++ b/provider/const_test.go
@@ -0,0 +1,40 @@
+package provider
+
+import (
+	"context"
+	"testing"
+
+	"github.com/stretchr/testify/assert"
+)
+
+func TestConst(t *testing.T) {
+	p, err := NewConstFromConfig(context.TODO(), map[string]interface{}{"value": nil})
+	assert.NoError(t, err)
+
+	{
+		g, err := p.(StringProvider).StringGetter()
+		assert.NoError(t, err)
+
+		v, err := g()
+		assert.NoError(t, err)
+		assert.Equal(t, "", v)
+	}
+
+	{
+		g, err := p.(IntProvider).IntGetter()
+		assert.NoError(t, err)
+
+		v, err := g()
+		assert.NoError(t, err)
+		assert.Equal(t, int64(0), v)
+	}
+
+	{
+		g, err := p.(FloatProvider).FloatGetter()
+		assert.NoError(t, err)
+
+		v, err := g()
+		assert.NoError(t, err)
+		assert.Equal(t, float64(0), v)
+	}
+}
diff --git a/server/influxdb_test.go b/server/influxdb_test.go
index 435483f9..fa55323d 100644
--- a/server/influxdb_test.go
+++ b/server/influxdb_test.go
@@ -7,84 +7,103 @@ import (
 	"github.com/evcc-io/evcc/util"
 	inf2 "github.com/influxdata/influxdb-client-go/v2"
 	"github.com/influxdata/influxdb-client-go/v2/api/write"
-	"github.com/stretchr/testify/assert"
+	"github.com/samber/lo"
+	"github.com/stretchr/testify/suite"
 )
 
-type influxWriter struct {
-	t   *testing.T
-	p   []*write.Point
-	idx int
+func TestInfluxTypes(t *testing.T) {
+	suite.Run(t, new(influxSuite))
+}
+
+type influxSuite struct {
+	suite.Suite
+	*Influx
+	p []*write.Point
 }
 
-func (w *influxWriter) WritePoint(p *write.Point) {
-	if w.idx >= len(w.p) {
-		w.t.Fatal("too many points")
+func (suite *influxSuite) SetupSuite() {
+	suite.Influx = &Influx{
+		log:   util.NewLogger("foo"),
+		clock: clock.NewMock(),
 	}
+}
 
-	assert.Equal(w.t, w.p[w.idx], p)
-	w.idx++
+func (suite *influxSuite) SetupTest() {
+	suite.p = nil
 }
 
-func (w *influxWriter) finish() {
-	assert.Len(w.t, w.p, w.idx, "not enough points")
+func (suite *influxSuite) WritePoint(p *write.Point) {
+	suite.p = append(suite.p, p)
 }
 
-func TestInfluxTypes(t *testing.T) {
-	m := &Influx{
-		log:   util.NewLogger("foo"),
-		clock: clock.NewMock(),
-	}
+func (suite *influxSuite) WriteParam(p util.Param) {
+	tags := make(map[string]string)
+	suite.Influx.writeComplexPoint(suite, p.Key, p.Val, tags)
+}
 
-	{
-		// string value
-		w := &influxWriter{
-			t: t, p: []*write.Point{inf2.NewPoint("foo", nil, map[string]any{"value": 1}, m.clock.Now())},
-		}
-		m.writeComplexPoint(w, util.Param{Key: "foo", Val: 1}, nil)
-		w.finish()
-	}
+func (w *influxSuite) TestString() {
+	w.WriteParam(util.Param{Key: "foo", Val: 1})
+	w.Equal([]*write.Point{inf2.NewPoint("foo", nil, map[string]any{"value": 1}, w.clock.Now())}, w.p)
+}
 
-	{
-		// nil value - https://github.com/evcc-io/evcc/issues/5950
-		w := &influxWriter{
-			t: t, p: []*write.Point{inf2.NewPoint("phasesConfigured", nil, map[string]any{"value": nil}, m.clock.Now())},
-		}
-		m.writeComplexPoint(w, util.Param{Key: "phasesConfigured", Val: nil}, nil)
-		w.finish()
-	}
+// bool is not published
+// func (w *influxSuite) TestBool() {
+// 	w.WriteParam(util.Param{Key: "foo", Val: false})
+// 	w.Equal([]*write.Point{inf2.NewPoint("foo", nil, map[string]any{"value": "false"}, w.clock.Now())}, w.p)
+// }
 
-	{
-		// phases array
-		w := &influxWriter{
-			t: t, p: []*write.Point{inf2.NewPoint("foo", nil, map[string]any{
-				"l1": 1.0,
-				"l2": 2.0,
-				"l3": 3.0,
-			}, m.clock.Now())},
-		}
-		m.writeComplexPoint(w, util.Param{Key: "foo", Val: [3]float64{1, 2, 3}}, nil)
-		w.finish()
-	}
+func (w *influxSuite) TestNil() {
+	// nil value - https://github.com/evcc-io/evcc/issues/5950
+	w.WriteParam(util.Param{Key: "foo", Val: nil})
+	w.Equal([]*write.Point{inf2.NewPoint("foo", nil, map[string]any{"value": nil}, w.clock.Now())}, w.p)
+}
 
-	{
-		// phases slice
-		w := &influxWriter{
-			t: t, p: []*write.Point{inf2.NewPoint("foo", nil, map[string]any{
-				"l1": 1.0,
-				"l2": 2.0,
-				"l3": 3.0,
-			}, m.clock.Now())},
-		}
-		m.writeComplexPoint(w, util.Param{Key: "foo", Val: []float64{1, 2, 3}}, nil)
-		w.finish()
-	}
+func (w *influxSuite) TestPointer() {
+	w.WriteParam(util.Param{Key: "foo", Val: lo.ToPtr(1)})
+	w.Equal([]*write.Point{inf2.NewPoint("foo", nil, map[string]any{"value": 1}, w.clock.Now())}, w.p)
+}
 
-	{
-		// arbitrary slice
-		w := &influxWriter{
-			t: t, p: nil,
-		}
-		m.writeComplexPoint(w, util.Param{Key: "foo", Val: []float64{1, 2, 3, 4}}, nil)
-		w.finish()
-	}
+func (w *influxSuite) TestArray() {
+	// nil value - https://github.com/evcc-io/evcc/issues/5950
+	w.WriteParam(util.Param{Key: "foo", Val: [3]float64{1, 2, 3}})
+	w.Equal([]*write.Point{inf2.NewPoint("foo", nil, map[string]any{
+		"l1": 1.0,
+		"l2": 2.0,
+		"l3": 3.0,
+	}, w.clock.Now())}, w.p)
+}
+
+func (w *influxSuite) TestPhasesSlice() {
+	w.WriteParam(util.Param{Key: "foo", Val: []float64{1, 2, 3}})
+	w.Equal([]*write.Point{inf2.NewPoint("foo", nil, map[string]any{
+		"l1": 1.0,
+		"l2": 2.0,
+		"l3": 3.0,
+	}, w.clock.Now())}, w.p)
+}
+
+func (w *influxSuite) TestSlice() {
+	w.WriteParam(util.Param{Key: "foo", Val: []float64{1, 2, 3, 4}})
+	w.Len(w.p, 0)
+}
+
+func (w *influxSuite) TestMeasurement() {
+	w.WriteParam(util.Param{Key: "battery", Val: measurement{Power: 1, Soc: lo.ToPtr(10.0)}})
+	w.Equal([]*write.Point{
+		inf2.NewPoint("batteryPower", nil, map[string]any{"value": 1.0}, w.clock.Now()),
+		inf2.NewPoint("batterySoc", nil, map[string]any{"value": 10.0}, w.clock.Now()),
+	}, w.p)
+}
+
+func (w *influxSuite) TestSliceOfStruct() {
+	w.WriteParam(util.Param{Key: "grid", Val: []measurement{
+		{Power: 1, Soc: lo.ToPtr(10.0)},
+		{Power: 2, Soc: lo.ToPtr(20.0)},
+	}})
+	w.Equal([]*write.Point{
+		inf2.NewPoint("gridPower", map[string]string{"id": "1"}, map[string]any{"value": 1.0}, w.clock.Now()),
+		inf2.NewPoint("gridSoc", map[string]string{"id": "1"}, map[string]any{"value": 10.0}, w.clock.Now()),
+		inf2.NewPoint("gridPower", map[string]string{"id": "2"}, map[string]any{"value": 2.0}, w.clock.Now()),
+		inf2.NewPoint("gridSoc", map[string]string{"id": "2"}, map[string]any{"value": 20.0}, w.clock.Now()),
+	}, w.p)
 }
diff --git a/server/modbus/proxy_test.go b/server/modbus/proxy_test.go
index 84cb382c..5eb258dc 100644
--- a/server/modbus/proxy_test.go
+++ b/server/modbus/proxy_test.go
@@ -37,7 +37,7 @@ func TestConcurrentRead(t *testing.T) {
 			conn, err := modbus.NewConnection(l.Addr().String(), "", "", 0, modbus.Tcp, uint8(id))
 			require.NoError(t, err)
 
-			for i := 0; i < 50; i++ {
+			for range 50 {
 				addr := uint16(rand.Int31n(200) + 1)
 				qty := uint16(rand.Int31n(32) + 1)
 
diff --git a/server/mqtt_test.go b/server/mqtt_test.go
index a7dd1347..dca29142 100644
--- a/server/mqtt_test.go
+++ b/server/mqtt_test.go
@@ -2,12 +2,14 @@ package server
 
 import (
 	"math"
+	"slices"
 	"strconv"
 	"testing"
 	"time"
 
+	"github.com/samber/lo"
 	"github.com/stretchr/testify/assert"
-	"github.com/stretchr/testify/require"
+	"github.com/stretchr/testify/suite"
 )
 
 func TestMqttNaNInf(t *testing.T) {
@@ -16,51 +18,108 @@ func TestMqttNaNInf(t *testing.T) {
 	assert.Equal(t, "+Inf", m.encode(math.Inf(0)), "Inf not encoded as string")
 }
 
-func TestPublishTypes(t *testing.T) {
-	var topics, payloads []string
+type measurement struct {
+	Power        float64   `json:"power"`
+	Energy       float64   `json:"energy,omitempty"`
+	Currents     []float64 `json:"currents,omitempty"`
+	Soc          *float64  `json:"soc,omitempty"`
+	Controllable *bool     `json:"controllable,omitempty"`
+}
+
+func TestMqttTypes(t *testing.T) {
+	suite.Run(t, new(mqttSuite))
+}
 
-	reset := func() {
-		topics = topics[:0]
-		payloads = payloads[:0]
+type mqttSuite struct {
+	suite.Suite
+	*MQTT
+	topics, payloads []string
+}
+
+func (suite *mqttSuite) publish(topic string, retained bool, payload interface{}) {
+	suite.MQTT.publish(topic, retained, payload)
+}
+
+func (suite *mqttSuite) publisher(topic string, retained bool, payload string) {
+	if i := slices.Index(suite.topics, topic); i >= 0 {
+		suite.topics[i] = topic
+		suite.payloads[i] = payload
+	} else {
+		suite.topics = append(suite.topics, topic)
+		suite.payloads = append(suite.payloads, payload)
 	}
+}
 
-	m := &MQTT{
-		publisher: func(topic string, retained bool, payload string) {
-			topics = append(topics, topic)
-			payloads = append(payloads, payload)
-		},
+func (suite *mqttSuite) SetupSuite() {
+	suite.MQTT = &MQTT{
+		publisher: suite.publisher,
 	}
+}
+
+func (suite *mqttSuite) SetupTest() {
+	suite.topics = suite.topics[:0]
+	suite.payloads = suite.payloads[:0]
+}
 
+func (suite *mqttSuite) TestTime() {
 	now := time.Now()
-	m.publish("test", false, now)
-	require.Len(t, topics, 1)
-	assert.Equal(t, strconv.FormatInt(now.Unix(), 10), payloads[0], "time not encoded as unix timestamp")
-	reset()
+	suite.publish("test", false, now)
+	suite.Require().Len(suite.topics, 1)
+	suite.Equal(strconv.FormatInt(now.Unix(), 10), suite.payloads[0], "time not encoded as unix timestamp")
+}
+
+func (suite *mqttSuite) TestBool() {
+	suite.publish("test", false, false)
+	suite.Require().Len(suite.topics, 1)
+	suite.Equal("false", suite.payloads[0])
+}
 
-	m.publish("test", false, struct {
+func (suite *mqttSuite) TestStruct() {
+	suite.publish("test", false, struct {
 		Foo string
 	}{
 		Foo: "bar",
 	})
-	assert.Equal(t, []string{"test/foo"}, topics, "struct mismatch")
-	assert.Equal(t, []string{"bar"}, payloads, "struct mismatch")
-	reset()
+	suite.Equal([]string{"test/foo"}, suite.topics, "topics")
+	suite.Equal([]string{"bar"}, suite.payloads, "payloads")
+}
 
+func (suite *mqttSuite) TestStructPointer() {
 	i := 1
-	m.publish("test", false, struct {
+	suite.publish("test", false, struct {
 		Foo, Bar *int
 	}{
 		Foo: &i,
 		Bar: nil,
 	})
-	assert.Equal(t, []string{"test/foo", "test/bar"}, topics, "pointer mismatch")
-	assert.Equal(t, []string{"1", ""}, payloads, "pointer mismatch")
-	reset()
+	suite.Equal([]string{"test/foo", "test/bar"}, suite.topics, "topics")
+	suite.Equal([]string{"1", ""}, suite.payloads, "payloads")
+}
 
+func (suite *mqttSuite) TestSlice() {
 	slice := []int{10, 20}
-	m.publish("test", false, slice)
-	require.Len(t, topics, 3)
-	assert.Equal(t, []string{"test", "test/1", "test/2"}, topics, "slice mismatch")
-	assert.Equal(t, []string{"2", "10", "20"}, payloads, "slice mismatch")
-	reset()
+	suite.publish("test", false, slice)
+	suite.Require().Len(suite.topics, 3)
+	suite.Equal([]string{"test", "test/1", "test/2"}, suite.topics, "topics")
+	suite.Equal([]string{"2", "10", "20"}, suite.payloads, "payloads")
+}
+
+func (suite *mqttSuite) TestGrid() {
+	topics := []string{"test/power", "test/energy", "test/currents", "test/soc", "test/controllable"}
+
+	suite.publish("test", false, measurement{})
+	suite.Equal(topics, suite.topics, "topics")
+	suite.Equal([]string{"0", "", "", "", ""}, suite.payloads, "payloads")
+
+	suite.publish("test", false, measurement{Energy: 1})
+	suite.Equal(topics, suite.topics, "topics")
+	suite.Equal([]string{"0", "1", "", "", ""}, suite.payloads, "payloads")
+
+	suite.publish("test", false, measurement{Controllable: lo.ToPtr(false)})
+	suite.Equal(topics, suite.topics, "topics")
+	suite.Equal([]string{"0", "", "", "", "false"}, suite.payloads, "payloads")
+
+	suite.publish("test", false, measurement{Currents: []float64{1, 2, 3}})
+	suite.Equal(append(topics, "test/currents/1", "test/currents/2", "test/currents/3"), suite.topics, "topics")
+	suite.Equal([]string{"0", "", "3", "", "", "1", "2", "3"}, suite.payloads, "payloads")
 }
diff --git a/tariff/fixed_test.go b/tariff/fixed_test.go
index 3dab95c2..d757d21a 100644
--- a/tariff/fixed_test.go
+++ b/tariff/fixed_test.go
@@ -54,7 +54,7 @@ func TestFixedSplitZones(t *testing.T) {
 	tf.clock = clock.NewMock()
 
 	var expect api.Rates
-	for i := 0; i < 7; i++ {
+	for i := range 7 {
 		dayStart := now.With(tf.clock.Now()).BeginningOfDay().AddDate(0, 0, i)
 
 		// 00:00-05:00 0.1
diff --git a/tariff/template_test.go b/tariff/template_test.go
index 53f8684b..189fcc6d 100644
--- a/tariff/template_test.go
+++ b/tariff/template_test.go
@@ -4,16 +4,18 @@ import (
 	"context"
 	"testing"
 
+	"github.com/evcc-io/evcc/api"
 	"github.com/evcc-io/evcc/util/templates"
 	"github.com/evcc-io/evcc/util/test"
 )
 
 var acceptable = []string{
-	"missing token",         // amber, tibber
-	"invalid zipcode",       // grünstromindex
-	"invalid apikey format", // octopusenergy
-	"missing region",        // octopusenergy
-	"missing securitytoken", // entsoe
+	api.ErrMissingCredentials.Error(),
+	api.ErrMissingToken.Error(),
+	"invalid zipcode",                                  // grünstromindex
+	"invalid apikey format",                            // octopusenergy
+	"missing region",                                   // octopusenergy
+	"missing securitytoken",                            // entsoe
 	"cannot define region and postcode simultaneously", // ngeso
 }
 
diff --git a/tests/battery-settings.spec.js b/tests/battery-settings.spec.js
index 4dba33b1..6fd3ead5 100644
--- a/tests/battery-settings.spec.js
+++ b/tests/battery-settings.spec.js
@@ -48,7 +48,11 @@ test.describe("battery settings", async () => {
     await page.getByRole("button", { name: "Close" }).click();
     await expect(page.getByTestId("battery-settings-modal")).not.toBeVisible();
     await page.getByTestId("energyflow").click();
-    await page.getByRole("button", { name: "cheap grid energy (≤ 50.0 ct)" }).click();
+    await page.getByRole("button", { name: "grid charging active (≤ 50.0 ct)" }).click();
     await expect(page.getByTestId("battery-settings-modal")).toBeVisible();
+    await page.getByLabel("Price limit").selectOption({ label: "≤ -10.0 ct/kWh" });
+    await page.getByRole("button", { name: "Close" }).click();
+    await expect(page.getByTestId("battery-settings-modal")).not.toBeVisible();
+    await expect(page.getByRole("button", { name: "grid charging when ≤ -10.0 ct" })).toBeVisible();
   });
 });
diff --git a/vehicle/template_test.go b/vehicle/template_test.go
index f542b0cc..07f6ac96 100644
--- a/vehicle/template_test.go
+++ b/vehicle/template_test.go
@@ -4,11 +4,15 @@ import (
 	"context"
 	"testing"
 
+	"github.com/evcc-io/evcc/api"
 	"github.com/evcc-io/evcc/util/templates"
 	"github.com/evcc-io/evcc/util/test"
 )
 
 var acceptable = []string{
+	api.ErrMissingCredentials.Error(),
+	api.ErrMissingToken.Error(),
+	"missing client id",
 	"invalid plugin source: ...",
 	"missing mqtt broker configuration",
 	"received status code 404 (INVALID PARAMS)", // Nissan
@@ -20,12 +24,9 @@ var acceptable = []string{
 	"network is unreachable",
 	"error connecting: Network Error",
 	"unexpected status: 401",
-	"missing credentials",    // Tesla
-	"missing credentials id", // Tronity
-	"missing access and/or refresh token, use `evcc token` to create", // Tesla
-	"login failed: code not found",                                    // Polestar
-	"empty instance type- check for missing usage",                    // Merces
-	"invalid vehicle type: tesla",                                     // Tesla
+	"discussions/17501",                            // Tesla
+	"login failed: code not found",                 // Polestar
+	"empty instance type- check for missing usage", // Merces
 }
 
 func TestTemplates(t *testing.T) {