Repository: evcc-io/evcc
Pull request #15192 (2024-08-02 07:26:30)

Cfos: add attached meter
Refs https://github.com/evcc-io/evcc/pull/15167


Patch:
diff --git a/api/rates.go b/api/rates.go
index afe05fd3..578a59aa 100644
--- a/api/rates.go
+++ b/api/rates.go
@@ -31,7 +31,7 @@ func (r Rates) Sort() {
 // Current returns the rates current rate or error
 func (r Rates) Current(now time.Time) (Rate, error) {
 	for _, rr := range r {
-		if (rr.Start.Before(now) || rr.Start.Equal(now)) && rr.End.After(now) {
+		if !rr.Start.After(now) && rr.End.After(now) {
 			return rr, nil
 		}
 	}
diff --git a/assets/css/app.css b/assets/css/app.css
index f7880b5e..4c7a5265 100644
--- a/assets/css/app.css
+++ b/assets/css/app.css
@@ -184,6 +184,13 @@ a:hover {
 	--bs-btn-border-width: 2px;
 }
 
+.btn-reset {
+	border: none;
+	background: none;
+	padding: 0;
+	color: inherit;
+}
+
 .btn-primary,
 .btn-primary:focus {
 	background-color: var(--bs-primary);
diff --git a/assets/js/components/BatterySettingsModal.vue b/assets/js/components/BatterySettingsModal.vue
index 9fa382ba..a5554035 100644
--- a/assets/js/components/BatterySettingsModal.vue
+++ b/assets/js/components/BatterySettingsModal.vue
@@ -1,252 +1,270 @@
 <template>
-	<Teleport to="body">
-		<div
-			id="batterySettingsModal"
-			ref="modal"
-			class="modal fade text-dark modal-l"
-			data-bs-backdrop="true"
-			tabindex="-1"
-			role="dialog"
-			aria-hidden="true"
-		>
-			<div class="modal-dialog modal-dialog-centered" role="document">
-				<div class="modal-content">
-					<div class="modal-header">
-						<h5 class="modal-title">
-							{{ $t("batterySettings.modalTitle") }}
-						</h5>
-						<button
-							type="button"
-							class="btn-close"
-							data-bs-dismiss="modal"
-							aria-label="Close"
-						></button>
+	<GenericModal
+		id="batterySettingsModal"
+		:title="$t('batterySettings.modalTitle')"
+		size="xl"
+		data-testid="battery-settings-modal"
+		@open="modalVisible"
+		@closed="modalInvisible"
+	>
+		<ul v-if="gridChargePossible" class="nav nav-tabs mb-4">
+			<li class="nav-item">
+				<a
+					class="nav-link"
+					:class="{ active: usageTabActive }"
+					href="#"
+					@click.prevent="showUsageTab"
+				>
+					{{ $t("batterySettings.usageTab") }}
+				</a>
+			</li>
+			<li class="nav-item">
+				<a
+					class="nav-link"
+					:class="{ active: gridTabActive }"
+					href="#"
+					@click.prevent="showGridTab"
+				>
+					{{ $t("batterySettings.gridChargeTab") }} 🧪
+				</a>
+			</li>
+		</ul>
+
+		<div class="row" v-show="usageTabActive">
+			<p class="text-center text-md-start col-md-6 order-md-2 col-lg-3 order-lg-3 pt-lg-2">
+				{{ $t("batterySettings.batteryLevel") }}:
+				<strong>{{ fmtSoc(batterySoc) }}</strong>
+				<small v-for="(line, index) in batteryDetails" :key="index" class="d-block">
+					{{ line }}
+				</small>
+			</p>
+			<div
+				class="col-md-6 order-md-1 col-lg-3 order-lg-2 mb-5 mb-lg-0 battery justify-content-center justify-content-md-start"
+			>
+				<div class="batteryLimits">
+					<CustomSelect
+						id="batterySettingsBuffer"
+						:options="bufferOptions"
+						:selected="selectedBufferSoc"
+						class="bufferSoc p-2 end-0"
+						:class="{
+							'bufferSoc--hidden': selectedBufferSoc === selectedPrioritySoc,
+						}"
+						:style="{ top: `${topHeight}%` }"
+						@change="changeBufferSoc"
+					>
+						<span class="text-decoration-underline text-nowrap pe-none">
+							{{ fmtSoc(selectedBufferSoc) }}
+						</span>
+					</CustomSelect>
+
+					<CustomSelect
+						id="batterySettingsPriority"
+						:options="priorityOptions"
+						:selected="selectedPrioritySoc"
+						class="prioritySoc p-2 end-0"
+						:style="{ top: `${100 - bottomHeight}%` }"
+						@change="changePrioritySoc"
+					>
+						<span class="text-decoration-underline text-nowrap pe-none">
+							{{ fmtSoc(selectedPrioritySoc) }}
+						</span>
+					</CustomSelect>
+				</div>
+				<div class="progress me-md-0">
+					<div
+						class="bg-dark-green progress-bar text-light align-items-center"
+						role="button"
+						:style="{ height: `${topHeight}%` }"
+						@click="toggleBufferStart"
+					>
+						<shopicon-regular-lightning
+							size="m"
+							class="icon"
+							:style="iconStyle(topHeight)"
+						></shopicon-regular-lightning>
+					</div>
+					<div
+						class="bg-darker-green progress-bar text-light align-items-center"
+						:style="{ height: `${middleHeight}%` }"
+					>
+						<shopicon-regular-car3
+							size="m"
+							class="icon"
+							:style="iconStyle(middleHeight)"
+						></shopicon-regular-car3>
 					</div>
-					<div class="modal-body">
-						<div
-							class="d-flex justify-content-between align-items-center align-items-sm-start pb-2 flex-column flex-sm-row-reverse"
+					<div
+						class="bg-darkest-green progress-bar text-light align-items-center"
+						:style="{ height: `${bottomHeight}%` }"
+					>
+						<shopicon-regular-home
+							size="m"
+							class="icon"
+							:style="iconStyle(bottomHeight)"
+						></shopicon-regular-home>
+					</div>
+					<div
+						class="batterySoc ps-0 bg-white pe-none"
+						:style="{ top: `${100 - batterySoc}%` }"
+					></div>
+					<div
+						class="bufferStartIndicator pe-none"
+						:class="{
+							'bufferStartIndicator--hidden':
+								!selectedBufferStartSoc || selectedBufferSoc === 100,
+						}"
+						:style="{ top: `${bufferStartTop}%` }"
+					>
+						<div class="bufferStartIndicator__left"></div>
+						<div class="bufferStartIndicator__right"></div>
+					</div>
+				</div>
+			</div>
+			<div class="col-md-12 order-md-3 col-lg-6 order-lg-1 legend pt-lg-2">
+				<p class="d-flex">
+					<shopicon-regular-lightning
+						size="s"
+						class="flex-shrink-0 me-2"
+					></shopicon-regular-lightning>
+					<span class="d-block">
+						{{ $t("batterySettings.legendTopName") }}
+						<i18n-t
+							keypath="batterySettings.legendTopSubline"
+							tag="small"
+							class="d-block"
+							scope="global"
 						>
-							<div class="battery mb-5 mb-sm-3 me-5 me-sm-0 w-sm-50">
-								<div class="batteryLimits">
-									<label
-										class="bufferSoc p-2 end-0"
-										role="button"
-										:class="{
-											'bufferSoc--hidden':
-												selectedBufferSoc === selectedPrioritySoc,
-										}"
-										:style="{ top: `${topHeight}%` }"
-									>
-										<select
-											:value="selectedBufferSoc"
-											class="custom-select"
-											@change="changeBufferSoc"
-										>
-											<option
-												v-for="{ value, name, disabled } in bufferOptions"
-												:key="value"
-												:value="value"
-												:disabled="disabled"
-											>
-												{{ name }}
-											</option>
-										</select>
-										<span class="text-decoration-underline text-nowrap pe-none">
-											{{ fmtSoc(selectedBufferSoc) }}
-										</span>
-									</label>
-									<label
-										class="prioritySoc p-2 end-0"
-										role="button"
-										:style="{ top: `${100 - bottomHeight}%` }"
-									>
-										<select
-											:value="selectedPrioritySoc"
-											class="custom-select"
-											@change="changePrioritySoc"
-										>
-											<option
-												v-for="{ value, name, disabled } in priorityOptions"
-												:key="value"
-												:value="value"
-												:disabled="disabled"
-											>
-												{{ name }}
-											</option>
-										</select>
-										<span class="text-decoration-underline text-nowrap pe-none">
-											{{ fmtSoc(selectedPrioritySoc) }}
-										</span>
-									</label>
-								</div>
-								<div class="progress">
-									<div
-										class="bg-dark-green progress-bar text-light align-items-center"
-										role="button"
-										:style="{ height: `${topHeight}%` }"
-										@click="toggleBufferStart"
-									>
-										<shopicon-regular-lightning
-											size="m"
-											class="icon"
-											:style="iconStyle(topHeight)"
-										></shopicon-regular-lightning>
-									</div>
-									<div
-										class="bg-darker-green progress-bar text-light align-items-center"
-										:style="{ height: `${middleHeight}%` }"
-									>
-										<shopicon-regular-car3
-											size="m"
-											class="icon"
-											:style="iconStyle(middleHeight)"
-										></shopicon-regular-car3>
-									</div>
-									<div
-										class="bg-darkest-green progress-bar text-light align-items-center"
-										:style="{ height: `${bottomHeight}%` }"
-									>
-										<shopicon-regular-home
-											size="m"
-											class="icon"
-											:style="iconStyle(bottomHeight)"
-										></shopicon-regular-home>
-									</div>
-									<div
-										class="batterySoc ps-0 bg-white pe-none"
-										:style="{ top: `${100 - batterySoc}%` }"
-									></div>
-									<div
-										class="bufferStartIndicator pe-none"
-										:class="{
-											'bufferStartIndicator--hidden':
-												!selectedBufferStartSoc ||
-												selectedBufferSoc === 100,
-										}"
-										:style="{ top: `${bufferStartTop}%` }"
-									>
-										<div class="bufferStartIndicator__left"></div>
-										<div class="bufferStartIndicator__right"></div>
-									</div>
-								</div>
-							</div>
-							<div class="legend me-sm-4 align-self-start w-sm-50">
-								<p>
-									{{ $t("batterySettings.batteryLevel") }}:
-									<strong>{{ fmtSoc(batterySoc) }}</strong>
-									<small
-										v-for="(line, index) in batteryDetails"
-										:key="index"
-										class="d-block"
-									>
-										{{ line }}
-									</small>
-								</p>
-								<p>{{ $t("batterySettings.legendTitle") }}</p>
-								<p class="d-flex">
-									<shopicon-regular-lightning
-										size="s"
-										class="flex-shrink-0 me-2"
-									></shopicon-regular-lightning>
-									<span class="d-block">
-										{{ $t("batterySettings.legendTopName") }}
-										<small v-if="selectedBufferSoc == 100" class="d-block">
-											{{ $t("batterySettings.legendTopSubline") }}
-										</small>
-										<small v-else class="d-block">
-											{{ $t("batterySettings.legendTopAutostart") }}
-											<label
-												for="bufferStartSelect"
-												class="position-relative d-block"
-											>
-												<select
-													id="bufferStartSelect"
-													class="custom-select"
-													:value="selectedBufferStartSoc"
-													@change="changeBufferStart"
-												>
-													<option
-														v-for="option in bufferStartOptions"
-														:key="option.value"
-														:value="option.value"
-													>
-														{{ option.name }}
-													</option>
-												</select>
-												<span class="text-decoration-underline">
-													{{ bufferStartOption.name }}
-												</span>
-											</label>
-										</small>
+							<template #soc>
+								<CustomSelect
+									id="batterySettingsBufferTop"
+									class="custom-select-inline"
+									:options="bufferOptions"
+									:selected="selectedBufferSoc"
+									@change="changeBufferSoc"
+								>
+									<span class="text-decoration-underline">
+										{{ fmtSoc(selectedBufferSoc) }}
 									</span>
-								</p>
-								<p class="d-flex">
-									<shopicon-regular-car3
-										size="s"
-										class="flex-shrink-0 me-2"
-									></shopicon-regular-car3>
-									<span class="d-block">
-										{{ $t("batterySettings.legendMiddleName") }}
-										<small class="d-block">
-											{{ $t("batterySettings.legendMiddleSubline") }}
-										</small>
+								</CustomSelect>
+							</template>
+						</i18n-t>
+
+						<small class="d-block">
+							{{ $t("batterySettings.legendTopAutostart") }}
+							<CustomSelect
+								id="batterySettingsBufferStart"
+								class="custom-select-inline"
+								:selected="selectedBufferStartSoc"
+								:options="bufferStartOptions"
+								@change="changeBufferStart"
+							>
+								<span class="text-decoration-underline">
+									{{ bufferStartOption.name }}
+								</span>
+							</CustomSelect>
+						</small>
+					</span>
+				</p>
+				<p class="d-flex">
+					<shopicon-regular-car3
+						size="s"
+						class="flex-shrink-0 me-2"
+					></shopicon-regular-car3>
+					<span class="d-block">
+						{{ $t("batterySettings.legendMiddleName") }}
+						<i18n-t
+							keypath="batterySettings.legendMiddleSubline"
+							tag="small"
+							class="d-block"
+							scope="global"
+						>
+							<template #soc>
+								<CustomSelect
+									id="batterySettingsPriorityMiddle"
+									class="custom-select-inline"
+									:options="priorityOptions"
+									:selected="selectedPrioritySoc"
+									@change="changePrioritySoc"
+								>
+									<span class="text-decoration-underline">
+										{{ fmtSoc(selectedPrioritySoc) }}
 									</span>
-								</p>
-								<p class="d-flex">
-									<shopicon-regular-home
-										size="s"
-										class="flex-shrink-0 me-2"
-									></shopicon-regular-home>
-									<span class="d-block">
-										{{ $t("batterySettings.legendBottomName") }}
-										<small class="d-block">
-											{{ $t("batterySettings.legendBottomSubline") }}
-										</small>
+								</CustomSelect>
+							</template>
+						</i18n-t>
+					</span>
+				</p>
+				<p class="d-flex">
+					<shopicon-regular-home
+						size="s"
+						class="flex-shrink-0 me-2"
+					></shopicon-regular-home>
+					<span class="d-block">
+						{{ $t("batterySettings.legendBottomName") }}
+						<i18n-t
+							keypath="batterySettings.legendBottomSubline"
+							tag="small"
+							class="d-block"
+							scope="global"
+						>
+							<template #soc>
+								<CustomSelect
+									id="batterySettingsPriorityBottom"
+									class="custom-select-inline"
+									:options="priorityOptions"
+									:selected="selectedPrioritySoc"
+									@change="changePrioritySoc"
+								>
+									<span class="text-decoration-underline">
+										{{ fmtSoc(selectedPrioritySoc) }}
 									</span>
-								</p>
-								<p class="small text-muted">
-									<strong class="text-evcc">
-										{{ $t("batterySettings.disclaimerHint") }}
-									</strong>
-									{{ $t("batterySettings.disclaimerText") }}
-								</p>
-							</div>
-						</div>
-						<div v-if="controllable">
-							<div class="form-check form-switch">
-								<input
-									id="batteryDischargeControl"
-									:checked="batteryDischargeControl"
-									class="form-check-input"
-									type="checkbox"
-									role="switch"
-									@change="changeDischargeControl"
-								/>
-								<div class="form-check-label">
-									<label for="batteryDischargeControl">
-										{{ $t("batterySettings.discharge") }}
-									</label>
-								</div>
-							</div>
-						</div>
+								</CustomSelect>
+							</template>
+						</i18n-t>
+					</span>
+				</p>
+				<div class="form-check form-switch mt-4" v-if="controllable">
+					<input
+						id="batteryDischargeControl"
+						:checked="batteryDischargeControl"
+						class="form-check-input"
+						type="checkbox"
+						role="switch"
+						@change="changeDischargeControl"
+					/>
+					<div class="form-check-label">
+						<label for="batteryDischargeControl">
+							{{ $t("batterySettings.discharge") }}
+						</label>
 					</div>
 				</div>
 			</div>
 		</div>
-	</Teleport>
+		<SmartCostLimit
+			v-if="gridChargePossible"
+			v-show="gridTabActive"
+			v-bind="smartCostLimitProps"
+		/>
+	</GenericModal>
 </template>
 
 <script>
 import "@h2d2/shopicons/es/regular/lightning";
 import "@h2d2/shopicons/es/regular/car3";
 import "@h2d2/shopicons/es/regular/home";
+import SmartCostLimit from "./SmartCostLimit.vue";
+import CustomSelect from "./CustomSelect.vue";
+import GenericModal from "./GenericModal.vue";
 import formatter from "../mixins/formatter";
 import collector from "../mixins/collector";
 import api from "../api";
+import smartCostAvailable from "../utils/smartCostAvailable";
 
 export default {
 	name: "BatterySettingsModal",
 	mixins: [formatter, collector],
+	components: { SmartCostLimit, CustomSelect, GenericModal },
 	props: {
 		bufferSoc: Number,
 		prioritySoc: Number,
@@ -254,6 +272,10 @@ export default {
 		bufferStartSoc: Number,
 		batteryDischargeControl: Boolean,
 		battery: { type: Array, default: () => [] },
+		batteryGridChargeLimit: Number,
+		smartCostType: String,
+		tariffGrid: Number,
+		currency: String,
 	},
 	data: function () {
 		return {
@@ -261,9 +283,13 @@ export default {
 			selectedBufferSoc: 100,
 			selectedPrioritySoc: 0,
 			selectedBufferStartSoc: 0,
+			gridTabActive: false,
 		};
 	},
 	computed: {
+		usageTabActive() {
+			return !this.gridTabActive;
+		},
 		priorityOptions() {
 			const options = [];
 			for (let i = 100; i >= 0; i -= 5) {
@@ -278,6 +304,14 @@ export default {
 		controllable() {
 			return this.battery.some(({ controllable }) => controllable);
 		},
+		gridChargePossible() {
+			return (
+				this.controllable &&
+				this.isModalVisible &&
+				this.smartCostAvailable &&
+				this.$hiddenFeatures()
+			);
+		},
 		bufferOptions() {
 			const options = [];
 			for (let i = 100; i >= 5; i -= 5) {
@@ -345,6 +379,15 @@ export default {
 					return `${name}${formattedEnergy}${formattedSoc}`;
 				});
 		},
+		smartCostLimitProps() {
+			return {
+				...this.collectProps(SmartCostLimit),
+				smartCostLimit: this.batteryGridChargeLimit,
+			};
+		},
+		smartCostAvailable() {
+			return smartCostAvailable(this.smartCostType);
+		},
 	},
 	watch: {
 		prioritySoc(soc) {
@@ -361,14 +404,14 @@ export default {
 		this.selectedBufferSoc = this.bufferSoc || 100;
 		this.selectedPrioritySoc = this.prioritySoc;
 		this.selectedBufferStartSoc = this.bufferStartSoc;
-		this.$refs.modal.addEventListener("show.bs.modal", this.modalVisible);
-		this.$refs.modal.addEventListener("hidden.bs.modal", this.modalInvisible);
-	},
-	unmounted() {
-		this.$refs.modal?.removeEventListener("show.bs.modal", this.modalVisible);
-		this.$refs.modal?.removeEventListener("hidden.bs.modal", this.modalInvisible);
 	},
 	methods: {
+		showGridTab() {
+			this.gridTabActive = true;
+		},
+		showUsageTab() {
+			this.gridTabActive = false;
+		},
 		modalVisible: function () {
 			this.isModalVisible = true;
 		},
@@ -451,14 +494,10 @@ export default {
 
 <style scoped>
 .battery {
-	height: 300px;
+	height: 285px;
 	display: flex;
 }
 
-.legend {
-	min-width: 260px;
-}
-
 .batteryLimits {
 	width: 50px;
 	position: relative;
@@ -467,7 +506,7 @@ export default {
 .bufferStart,
 .bufferSoc,
 .prioritySoc {
-	position: absolute;
+	position: absolute !important;
 	transform: translateY(-50%);
 	transition-property: top, opacity;
 	transition-timing-function: linear;
@@ -523,7 +562,8 @@ export default {
 	flex: 1;
 	height: 100%;
 	min-width: 100px;
-	max-width: 150px;
+	max-width: 130px;
+	margin-right: 50px;
 	flex-direction: column;
 	position: relative;
 	border-radius: 1rem;
@@ -537,14 +577,7 @@ export default {
 	z-index: 1;
 	border-radius: 0.5rem;
 }
-.custom-select {
-	left: 0;
-	top: 0;
-	bottom: 0;
-	right: 0;
-	cursor: pointer;
-	position: absolute;
-	opacity: 0;
-	-webkit-appearance: menulist-button;
+.custom-select-inline {
+	display: inline-block !important;
 }
 </style>
diff --git a/assets/js/components/Config/UserInterfaceSettings.vue b/assets/js/components/Config/UserInterfaceSettings.vue
index da0a905b..815021a3 100644
--- a/assets/js/components/Config/UserInterfaceSettings.vue
+++ b/assets/js/components/Config/UserInterfaceSettings.vue
@@ -57,11 +57,7 @@
 				</div>
 			</div>
 		</FormRow>
-		<FormRow
-			v-if="fullscreenAvailable"
-			id="settingsFullscreen"
-			:label="$t('settings.fullscreen.label')"
-		>
+		<FormRow v-if="fullscreenAvailable" :label="$t('settings.fullscreen.label')">
 			<button
 				v-if="fullscreenActive"
 				class="btn btn-sm btn-outline-secondary"
diff --git a/assets/js/components/Config/VehicleModal.vue b/assets/js/components/Config/VehicleModal.vue
index 6b3ba9bf..8de376ef 100644
--- a/assets/js/components/Config/VehicleModal.vue
+++ b/assets/js/components/Config/VehicleModal.vue
@@ -324,10 +324,15 @@ export default {
 			return this.templateParams.filter((p) => p.Advanced);
 		},
 		apiData() {
-			return {
+			const data = {
 				template: this.templateName,
 				...this.values,
 			};
+			// trim and remove empty lines
+			if (Array.isArray(data.identifiers)) {
+				data.identifiers = data.identifiers.map((i) => i.trim()).filter((i) => i);
+			}
+			return data;
 		},
 		isNew() {
 			return this.id === undefined;
diff --git a/assets/js/components/CustomSelect.vue b/assets/js/components/CustomSelect.vue
index 7a38b3dc..83d82776 100644
--- a/assets/js/components/CustomSelect.vue
+++ b/assets/js/components/CustomSelect.vue
@@ -19,7 +19,7 @@ export default {
 	name: "CustomSelect",
 	props: {
 		options: { type: Array },
-		selected: { type: String },
+		selected: { type: [String, Number] },
 		id: { type: String },
 	},
 	emits: ["change"],
diff --git a/assets/js/components/Energyflow/Energyflow.vue b/assets/js/components/Energyflow/Energyflow.vue
index 8074a336..c2bcc203 100644
--- a/assets/js/components/Energyflow/Energyflow.vue
+++ b/assets/js/components/Energyflow/Energyflow.vue
@@ -160,7 +160,17 @@
 							:detailsFmt="batteryFmt"
 							detailsClickable
 							@details-clicked="openBatterySettingsModal"
-						/>
+						>
+							<template v-if="batteryGridChargeActive" #subline>
+								<button
+									type="button"
+									class="btn-reset d-flex justify-content-between"
+									@click.stop="openBatterySettingsModal"
+								>
+									{{ batteryGridChargeText }} (≤ {{ batteryGridChargeLimitFmt }})
+								</button>
+							</template>
+						</EnergyflowEntry>
 						<EnergyflowEntry
 							v-if="pvPossible"
 							:name="$t('main.energyflow.pvExport')"
@@ -211,6 +221,8 @@ export default {
 		batteryPower: { type: Number, default: 0 },
 		batterySoc: { type: Number, default: 0 },
 		batteryDischargeControl: { type: Boolean },
+		batteryGridChargeLimit: { type: Number },
+		batteryGridChargeActive: { type: Boolean },
 		batteryMode: { type: String },
 		tariffGrid: { type: Number },
 		tariffFeedIn: { type: Number },
@@ -307,6 +319,26 @@ 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);
+		},
+		batteryGridChargeLimitFmt() {
+			if (this.batteryGridChargeLimit === null || this.batteryGridChargeLimit === undefined) {
+				return;
+			}
+			if (this.co2Available) {
+				return this.fmtCo2Short(this.batteryGridChargeLimit);
+			}
+			return this.fmtPricePerKWh(this.batteryGridChargeLimit, this.currency, true);
+		},
 	},
 	watch: {
 		pvConfigured() {
diff --git a/assets/js/components/Energyflow/EnergyflowEntry.vue b/assets/js/components/Energyflow/EnergyflowEntry.vue
index 0a055aef..81d042f3 100644
--- a/assets/js/components/Energyflow/EnergyflowEntry.vue
+++ b/assets/js/components/Energyflow/EnergyflowEntry.vue
@@ -1,29 +1,34 @@
 <template>
-	<div class="d-flex justify-content-between mb-2 entry" :class="{ 'evcc-gray': !active }">
-		<span class="d-flex flex-nowrap">
-			<BatteryIcon v-if="isBattery" :soc="soc" />
-			<VehicleIcon v-else-if="isVehicle" :names="vehicleIcons" />
-			<component :is="`shopicon-regular-${icon}`" v-else></component>
-		</span>
-		<span class="text-nowrap flex-grow-1 ms-3 text-truncate">
-			{{ name }}
-		</span>
-		<span class="text-end text-nowrap ps-1 fw-bold d-flex">
-			<div
-				ref="details"
-				class="fw-normal"
-				:class="{ 'text-decoration-underline': detailsClickable }"
-				data-testid="energyflow-entry-details"
-				data-bs-toggle="tooltip"
-				:tabindex="detailsClickable ? 0 : undefined"
-				@click="detailsClicked"
-			>
-				<AnimatedNumber v-if="!isNaN(details)" :to="details" :format="detailsFmt" />
+	<div class="mb-2 entry" :class="{ 'evcc-gray': !active }">
+		<div class="d-flex justify-content-between">
+			<span class="d-flex flex-nowrap">
+				<BatteryIcon v-if="isBattery" :soc="soc" />
+				<VehicleIcon v-else-if="isVehicle" :names="vehicleIcons" />
+				<component :is="`shopicon-regular-${icon}`" v-else></component>
+			</span>
+			<div class="d-block text-nowrap flex-grow-1 ms-3 text-truncate">
+				{{ name }}
 			</div>
-			<div ref="power" class="power" data-bs-toggle="tooltip" @click="powerClicked">
-				<AnimatedNumber ref="powerNumber" :to="power" :format="kw" />
-			</div>
-		</span>
+			<span class="text-end text-nowrap ps-1 fw-bold d-flex">
+				<div
+					ref="details"
+					class="fw-normal"
+					:class="{ 'text-decoration-underline': detailsClickable }"
+					data-testid="energyflow-entry-details"
+					data-bs-toggle="tooltip"
+					:tabindex="detailsClickable ? 0 : undefined"
+					@click="detailsClicked"
+				>
+					<AnimatedNumber v-if="!isNaN(details)" :to="details" :format="detailsFmt" />
+				</div>
+				<div ref="power" class="power" data-bs-toggle="tooltip" @click="powerClicked">
+					<AnimatedNumber ref="powerNumber" :to="power" :format="kw" />
+				</div>
+			</span>
+		</div>
+		<div v-if="$slots.subline" class="ms-4 ps-3">
+			<slot name="subline" />
+		</div>
 	</div>
 </template>
 
diff --git a/assets/js/components/LoadpointSettingsModal.vue b/assets/js/components/LoadpointSettingsModal.vue
index 2789a6eb..df9dff9f 100644
--- a/assets/js/components/LoadpointSettingsModal.vue
+++ b/assets/js/components/LoadpointSettingsModal.vue
@@ -30,9 +30,9 @@
 								v-bind="smartCostLimitProps"
 								class="mt-2"
 							/>
-							<h4 class="d-flex align-items-center mb-3 mt-5 text-evcc">
+							<h6>
 								{{ $t("main.loadpointSettings.currents") }}
-							</h4>
+							</h6>
 							<div v-if="phasesOptions.length" class="mb-3 row">
 								<label
 									:for="formId(`phases_${phasesOptions[0]}`)"
diff --git a/assets/js/components/OfflineIndicator.vue b/assets/js/components/OfflineIndicator.vue
index 04f35bee..b058846f 100644
--- a/assets/js/components/OfflineIndicator.vue
+++ b/assets/js/components/OfflineIndicator.vue
@@ -22,7 +22,11 @@
 				</button>
 				{{ $t("offline.restarting") }}
 			</div>
-			<div v-else-if="restartNeeded" class="d-flex align-items-center">
+			<div
+				v-else-if="restartNeeded"
+				class="d-flex align-items-center"
+				data-testid="restart-needed"
+			>
 				<button
 					class="btn btn-secondary me-2 btn-sm d-flex align-items-center"
 					type="button"
@@ -40,6 +44,7 @@
 			<div
 				v-else-if="showError"
 				class="d-flex align-items-start container px-4 justify-content-center"
+				data-testid="fatal-error"
 			>
 				<shopicon-regular-car1
 					size="m"
diff --git a/assets/js/components/Site.vue b/assets/js/components/Site.vue
index 58a09fdc..7e904a7a 100644
--- a/assets/js/components/Site.vue
+++ b/assets/js/components/Site.vue
@@ -72,7 +72,8 @@ export default {
 		batteryPower: Number,
 		batterySoc: Number,
 		batteryDischargeControl: Boolean,
-		batterySmartCostLimit: Number,
+		batteryGridChargeLimit: Number,
+		batteryGridChargeActive: Boolean,
 		batteryMode: String,
 		battery: Array,
 		gridCurrents: Array,
diff --git a/assets/js/components/SmartCostLimit.vue b/assets/js/components/SmartCostLimit.vue
index 08491e01..4db1dc2e 100644
--- a/assets/js/components/SmartCostLimit.vue
+++ b/assets/js/components/SmartCostLimit.vue
@@ -1,6 +1,6 @@
 <template>
 	<div>
-		<h4 class="text-evcc mb-3">{{ title }}</h4>
+		<h6 class="mt-0" v-if="isLoadpoint">{{ title }}</h6>
 		<p>
 			{{ description }}
 		</p>
@@ -15,7 +15,7 @@
 					class="form-select form-select-sm mb-1"
 					@change="changeSmartCostLimit"
 				>
-					<option value="0">{{ $t("smartCost.none") }}</option>
+					<option value="null">{{ $t("smartCost.none") }}</option>
 					<option v-for="{ value, name } in costOptions" :key="value" :value="value">
 						{{ name }}
 					</option>
@@ -94,7 +94,7 @@ export default {
 	},
 	data: function () {
 		return {
-			selectedSmartCostLimit: 0,
+			selectedSmartCostLimit: null,
 			tariff: null,
 			startTime: null,
 			activeIndex: null,
@@ -110,9 +110,7 @@ export default {
 			const stepSize = this.optionStepSize;
 			for (let i = 1; i <= 100; i++) {
 				const value = this.optionStartValue + stepSize * i;
-				if (value != 0) {
-					values.push(this.roundLimit(value));
-				}
+				values.push(this.roundLimit(value));
 			}
 			// add special entry if currently selected value is not in the scale
 			const selected = this.selectedSmartCostLimit;
@@ -172,7 +170,7 @@ export default {
 				// TODO: handle multiple matching time slots
 				const price = this.findSlotInRange(start, end, rates)?.price;
 				const charging =
-					price <= this.selectedSmartCostLimit && this.selectedSmartCostLimit !== 0;
+					price <= this.selectedSmartCostLimit && this.selectedSmartCostLimit !== null;
 				const selectable = price !== undefined;
 				result.push({ day, price, startHour, endHour, charging, selectable });
 			}
@@ -221,6 +219,9 @@ export default {
 		formId() {
 			return `smartCostLimit-${this.loadpointId || "battery"}`;
 		},
+		isLoadpoint() {
+			return !!this.loadpointId;
+		},
 	},
 	watch: {
 		tariffGrid() {
@@ -236,7 +237,7 @@ export default {
 	},
 	methods: {
 		roundLimit(limit) {
-			return limit ? Math.round(limit * 1000) / 1000 : 0;
+			return limit === null ? null : Math.round(limit * 1000) / 1000;
 		},
 		updateTariff: async function () {
 			try {
@@ -288,10 +289,6 @@ export default {
 			this.activeIndex = index;
 		},
 		setSelectedSmartCostLimit(limit) {
-			if (limit === 0) {
-				this.selectedSmartCostLimit = 0;
-				return;
-			}
 			const nextOption = this.costOptions.find(({ value }) => value >= limit);
 			if (nextOption) {
 				this.selectedSmartCostLimit = nextOption.value;
@@ -308,13 +305,18 @@ export default {
 			this.saveSmartCostLimit($event.target.value);
 		},
 		async saveSmartCostLimit(limit) {
-			const isLoadpoint = !!this.loadpointId;
-			const url = isLoadpoint
+			const url = this.isLoadpoint
 				? `loadpoints/${this.loadpointId}/smartcostlimit`
-				: "batterysmartcostlimit"; // currently not implemented
+				: "batterygridchargelimit";
+
+			// delete
 			try {
-				await api.post(`${url}/${encodeURIComponent(limit)}`);
-				if (isLoadpoint && this.multipleLoadpoints) {
+				if (limit === "null") {
+					await api.delete(url);
+				} else {
+					await api.post(`${url}/${encodeURIComponent(limit)}`);
+				}
+				if (this.isLoadpoint && this.multipleLoadpoints) {
 					this.applyToAllVisible = true;
 				}
 			} catch (err) {
@@ -323,7 +325,13 @@ export default {
 		},
 		async applyToAll() {
 			try {
-				await api.post(`smartcostlimit/${encodeURIComponent(this.selectedSmartCostLimit)}`);
+				if (this.selectedSmartCostLimit === null) {
+					await api.delete("smartcostlimit");
+				} else {
+					await api.post(
+						`smartcostlimit/${encodeURIComponent(this.selectedSmartCostLimit)}`
+					);
+				}
 				this.applyToAllVisible = false;
 			} catch (err) {
 				console.error(err);
diff --git a/assets/js/components/SponsorTokenExpires.vue b/assets/js/components/SponsorTokenExpires.vue
index 53e55d0c..cc57d079 100644
--- a/assets/js/components/SponsorTokenExpires.vue
+++ b/assets/js/components/SponsorTokenExpires.vue
@@ -1,6 +1,6 @@
 <template>
 	<div v-if="expiresSoon" class="alert alert-warning my-4" role="alert">
-		<i18n-t tag="div" keypath="settings.sponsorToken.expires">
+		<i18n-t tag="div" keypath="settings.sponsorToken.expires" scope="global">
 			<template #inXDays>
 				{{ inXDays }}
 			</template>
diff --git a/assets/js/components/TelemetrySettings.vue b/assets/js/components/TelemetrySettings.vue
index a9aadfd0..a973ea60 100644
--- a/assets/js/components/TelemetrySettings.vue
+++ b/assets/js/components/TelemetrySettings.vue
@@ -13,7 +13,12 @@
 		<div class="form-check-label">
 			<label for="telemetryEnabled">
 				{{ $t("footer.telemetry.optIn") }}
-				<i18n-t v-if="sponsorActive" tag="span" keypath="footer.telemetry.optInMoreDetails">
+				<i18n-t
+					v-if="sponsorActive"
+					tag="span"
+					keypath="footer.telemetry.optInMoreDetails"
+					scope="global"
+				>
 					<a :href="docsLink" target="_blank">
 						{{ $t("footer.telemetry.optInMoreDetailsLink") }}
 					</a>
diff --git a/assets/js/views/Config.vue b/assets/js/views/Config.vue
index 779b008b..a0b32bc2 100644
--- a/assets/js/views/Config.vue
+++ b/assets/js/views/Config.vue
@@ -231,7 +231,7 @@
 									>
 										<hr v-if="idx > 0" />
 										<p class="my-2 fw-bold">
-											{{ circuit.config.title }}
+											{{ circuit.config?.title }}
 											<code>({{ circuit.name }})</code>
 										</p>
 										<DeviceTags :tags="circuitTags(circuit)" />
diff --git a/charger/abb.go b/charger/abb.go
index 6d24b399..3fb47ce7 100644
--- a/charger/abb.go
+++ b/charger/abb.go
@@ -65,7 +65,7 @@ func NewABBFromConfig(other map[string]interface{}) (api.Charger, error) {
 		return nil, err
 	}
 
-	return NewABB(cc.URI, cc.Device, cc.Comset, cc.Baudrate, modbus.ProtocolFromRTU(cc.RTU), cc.ID)
+	return NewABB(cc.URI, cc.Device, cc.Comset, cc.Baudrate, cc.Protocol(), cc.ID)
 }
 
 // NewABB creates ABB charger
diff --git a/charger/alphatec.go b/charger/alphatec.go
index 56fa6dee..7e76df63 100644
--- a/charger/alphatec.go
+++ b/charger/alphatec.go
@@ -55,7 +55,7 @@ func NewAlphatecFromConfig(other map[string]interface{}) (api.Charger, error) {
 		return nil, err
 	}
 
-	return NewAlphatec(cc.URI, cc.Device, cc.Comset, cc.Baudrate, modbus.ProtocolFromRTU(cc.RTU), cc.ID)
+	return NewAlphatec(cc.URI, cc.Device, cc.Comset, cc.Baudrate, cc.Protocol(), cc.ID)
 }
 
 // NewAlphatec creates Alphatec charger
diff --git a/charger/cfos.go b/charger/cfos.go
index dd7a9dc3..a8e19b28 100644
--- a/charger/cfos.go
+++ b/charger/cfos.go
@@ -17,7 +17,13 @@ const (
 	cfosRegMaxCurrent   = 8093
 	cfosRegEnable       = 8094
 	cfosRegLastRfid     = 8096
+	cfosRegMeter        = 8112
 	cfosRegSolarEnabled = 8113
+
+	cfosRegMeterFlags = 8057
+	cfosRegEnergy     = 8058 //	4 rw Aktiver Import [Wh]
+	cfosRegPower      = 8062 //	2 r	Aktive Leistung [W]
+	cfosRegCurrents   = 8064 //	2 r	Momentaner Strom L1 [0.1 A]
 )
 
 // CfosPowerBrain is an charger implementation for cFos PowerBrain wallboxes.
@@ -31,7 +37,7 @@ func init() {
 	registry.Add("cfos", NewCfosPowerBrainFromConfig)
 }
 
-//go:generate go run ../cmd/tools/decorate.go -f decorateCfos -b *CfosPowerBrain -r api.Charger -t "api.PhaseSwitcher,Phases1p3p,func(int) error"
+//go:generate go run ../cmd/tools/decorate.go -f decorateCfos -b *CfosPowerBrain -r api.Charger -t "api.Meter,CurrentPower,func() (float64, error)" -t "api.MeterEnergy,TotalEnergy,func() (float64, error)" -t "api.PhaseCurrents,Currents,func() (float64, float64, float64, error)" -t "api.PhaseSwitcher,Phases1p3p,func(int) error"
 
 // NewCfosPowerBrainFromConfig creates a cFos charger from generic config
 func NewCfosPowerBrainFromConfig(other map[string]interface{}) (api.Charger, error) {
@@ -70,15 +76,28 @@ func NewCfosPowerBrain(uri string, id uint8) (api.Charger, error) {
 		conn: conn,
 	}
 
+	// decorate meter
+	var (
+		power    func() (float64, error)
+		energy   func() (float64, error)
+		currents func() (float64, float64, float64, error)
+	)
+	if b, err := wb.conn.ReadHoldingRegisters(cfosRegMeter, 1); err == nil && binary.BigEndian.Uint16(b) != 0 {
+		power = wb.currentPower
+		energy = wb.totalEnergy
+
+		if b, err := wb.conn.ReadHoldingRegisters(cfosRegMeterFlags, 1); err == nil && binary.BigEndian.Uint16(b) != 0 {
+			currents = wb.currents
+		}
+	}
+
 	// decorate phases
 	var phases1p3p func(int) error
-
-	b, err := wb.conn.ReadHoldingRegisters(cfosRegSolarEnabled, 1)
-	if err == nil && binary.BigEndian.Uint16(b)&(1<<8) != 0 {
+	if b, err := wb.conn.ReadHoldingRegisters(cfosRegSolarEnabled, 1); err == nil && binary.BigEndian.Uint16(b)&(1<<8) != 0 {
 		phases1p3p = wb.phases1p3p
 	}
 
-	return decorateCfos(wb, phases1p3p), nil
+	return decorateCfos(wb, power, energy, currents, phases1p3p), nil
 }
 
 // Status implements the api.Charger interface
@@ -141,6 +160,41 @@ func (wb *CfosPowerBrain) MaxCurrentMillis(current float64) error {
 	return err
 }
 
+// currentPower implements the api.Meter interface
+func (wb *CfosPowerBrain) currentPower() (float64, error) {
+	b, err := wb.conn.ReadHoldingRegisters(cfosRegPower, 2)
+	if err != nil {
+		return 0, err
+	}
+
+	return float64(binary.BigEndian.Uint32(b)), nil
+}
+
+// totalEnergy implements the api.MeterEnergy interface
+func (wb *CfosPowerBrain) totalEnergy() (float64, error) {
+	b, err := wb.conn.ReadHoldingRegisters(cfosRegEnergy, 4)
+	if err != nil {
+		return 0, err
+	}
+
+	return float64(binary.BigEndian.Uint64(b)) / 1e3, nil
+}
+
+// currents implements the api.PhaseCurrents interface
+func (wb *CfosPowerBrain) currents() (float64, float64, float64, error) {
+	b, err := wb.conn.ReadHoldingRegisters(cfosRegCurrents, 6)
+	if err != nil {
+		return 0, 0, 0, err
+	}
+
+	var res [3]float64
+	for i := range res {
+		res[i] = float64(binary.BigEndian.Uint32(b[4*i:])) / 10
+	}
+
+	return res[0], res[1], res[2], nil
+}
+
 // phases1p3p implements the api.PhaseSwitcher interface
 func (wb *CfosPowerBrain) phases1p3p(phases int) error {
 	if phases == 3 {
diff --git a/charger/cfos_decorators.go b/charger/cfos_decorators.go
index d527089e..54ab8acd 100644
--- a/charger/cfos_decorators.go
+++ b/charger/cfos_decorators.go
@@ -6,17 +6,239 @@ import (
 	"github.com/evcc-io/evcc/api"
 )
 
-func decorateCfos(base *CfosPowerBrain, phaseSwitcher func(int) error) api.Charger {
+func decorateCfos(base *CfosPowerBrain, meter func() (float64, error), meterEnergy func() (float64, error), phaseCurrents func() (float64, float64, float64, error), phaseSwitcher func(int) error) api.Charger {
 	switch {
-	case phaseSwitcher == nil:
+	case meter == nil && meterEnergy == nil && phaseCurrents == nil && phaseSwitcher == nil:
 		return base
 
-	case phaseSwitcher != nil:
+	case meter != nil && meterEnergy == nil && phaseCurrents == nil && phaseSwitcher == nil:
 		return &struct {
 			*CfosPowerBrain
+			api.Meter
+		}{
+			CfosPowerBrain: base,
+			Meter: &decorateCfosMeterImpl{
+				meter: meter,
+			},
+		}
+
+	case meter == nil && meterEnergy != nil && phaseCurrents == nil && phaseSwitcher == nil:
+		return &struct {
+			*CfosPowerBrain
+			api.MeterEnergy
+		}{
+			CfosPowerBrain: base,
+			MeterEnergy: &decorateCfosMeterEnergyImpl{
+				meterEnergy: meterEnergy,
+			},
+		}
+
+	case meter != nil && meterEnergy != nil && phaseCurrents == nil && phaseSwitcher == nil:
+		return &struct {
+			*CfosPowerBrain
+			api.Meter
+			api.MeterEnergy
+		}{
+			CfosPowerBrain: base,
+			Meter: &decorateCfosMeterImpl{
+				meter: meter,
+			},
+			MeterEnergy: &decorateCfosMeterEnergyImpl{
+				meterEnergy: meterEnergy,
+			},
+		}
+
+	case meter == nil && meterEnergy == nil && phaseCurrents != nil && phaseSwitcher == nil:
+		return &struct {
+			*CfosPowerBrain
+			api.PhaseCurrents
+		}{
+			CfosPowerBrain: base,
+			PhaseCurrents: &decorateCfosPhaseCurrentsImpl{
+				phaseCurrents: phaseCurrents,
+			},
+		}
+
+	case meter != nil && meterEnergy == nil && phaseCurrents != nil && phaseSwitcher == nil:
+		return &struct {
+			*CfosPowerBrain
+			api.Meter
+			api.PhaseCurrents
+		}{
+			CfosPowerBrain: base,
+			Meter: &decorateCfosMeterImpl{
+				meter: meter,
+			},
+			PhaseCurrents: &decorateCfosPhaseCurrentsImpl{
+				phaseCurrents: phaseCurrents,
+			},
+		}
+
+	case meter == nil && meterEnergy != nil && phaseCurrents != nil && phaseSwitcher == nil:
+		return &struct {
+			*CfosPowerBrain
+			api.MeterEnergy
+			api.PhaseCurrents
+		}{
+			CfosPowerBrain: base,
+			MeterEnergy: &decorateCfosMeterEnergyImpl{
+				meterEnergy: meterEnergy,
+			},
+			PhaseCurrents: &decorateCfosPhaseCurrentsImpl{
+				phaseCurrents: phaseCurrents,
+			},
+		}
+
+	case meter != nil && meterEnergy != nil && phaseCurrents != nil && phaseSwitcher == nil:
+		return &struct {
+			*CfosPowerBrain
+			api.Meter
+			api.MeterEnergy
+			api.PhaseCurrents
+		}{
+			CfosPowerBrain: base,
+			Meter: &decorateCfosMeterImpl{
+				meter: meter,
+			},
+			MeterEnergy: &decorateCfosMeterEnergyImpl{
+				meterEnergy: meterEnergy,
+			},
+			PhaseCurrents: &decorateCfosPhaseCurrentsImpl{
+				phaseCurrents: phaseCurrents,
+			},
+		}
+
+	case meter == nil && meterEnergy == nil && phaseCurrents == nil && phaseSwitcher != nil:
+		return &struct {
+			*CfosPowerBrain
+			api.PhaseSwitcher
+		}{
+			CfosPowerBrain: base,
+			PhaseSwitcher: &decorateCfosPhaseSwitcherImpl{
+				phaseSwitcher: phaseSwitcher,
+			},
+		}
+
+	case meter != nil && meterEnergy == nil && phaseCurrents == nil && phaseSwitcher != nil:
+		return &struct {
+			*CfosPowerBrain
+			api.Meter
+			api.PhaseSwitcher
+		}{
+			CfosPowerBrain: base,
+			Meter: &decorateCfosMeterImpl{
+				meter: meter,
+			},
+			PhaseSwitcher: &decorateCfosPhaseSwitcherImpl{
+				phaseSwitcher: phaseSwitcher,
+			},
+		}
+
+	case meter == nil && meterEnergy != nil && phaseCurrents == nil && phaseSwitcher != nil:
+		return &struct {
+			*CfosPowerBrain
+			api.MeterEnergy
+			api.PhaseSwitcher
+		}{
+			CfosPowerBrain: base,
+			MeterEnergy: &decorateCfosMeterEnergyImpl{
+				meterEnergy: meterEnergy,
+			},
+			PhaseSwitcher: &decorateCfosPhaseSwitcherImpl{
+				phaseSwitcher: phaseSwitcher,
+			},
+		}
+
+	case meter != nil && meterEnergy != nil && phaseCurrents == nil && phaseSwitcher != nil:
+		return &struct {
+			*CfosPowerBrain
+			api.Meter
+			api.MeterEnergy
+			api.PhaseSwitcher
+		}{
+			CfosPowerBrain: base,
+			Meter: &decorateCfosMeterImpl{
+				meter: meter,
+			},
+			MeterEnergy: &decorateCfosMeterEnergyImpl{
+				meterEnergy: meterEnergy,
+			},
+			PhaseSwitcher: &decorateCfosPhaseSwitcherImpl{
+				phaseSwitcher: phaseSwitcher,
+			},
+		}
+
+	case meter == nil && meterEnergy == nil && phaseCurrents != nil && phaseSwitcher != nil:
+		return &struct {
+			*CfosPowerBrain
+			api.PhaseCurrents
+			api.PhaseSwitcher
+		}{
+			CfosPowerBrain: base,
+			PhaseCurrents: &decorateCfosPhaseCurrentsImpl{
+				phaseCurrents: phaseCurrents,
+			},
+			PhaseSwitcher: &decorateCfosPhaseSwitcherImpl{
+				phaseSwitcher: phaseSwitcher,
+			},
+		}
+
+	case meter != nil && meterEnergy == nil && phaseCurrents != nil && phaseSwitcher != nil:
+		return &struct {
+			*CfosPowerBrain
+			api.Meter
+			api.PhaseCurrents
+			api.PhaseSwitcher
+		}{
+			CfosPowerBrain: base,
+			Meter: &decorateCfosMeterImpl{
+				meter: meter,
+			},
+			PhaseCurrents: &decorateCfosPhaseCurrentsImpl{
+				phaseCurrents: phaseCurrents,
+			},
+			PhaseSwitcher: &decorateCfosPhaseSwitcherImpl{
+				phaseSwitcher: phaseSwitcher,
+			},
+		}
+
+	case meter == nil && meterEnergy != nil && phaseCurrents != nil && phaseSwitcher != nil:
+		return &struct {
+			*CfosPowerBrain
+			api.MeterEnergy
+			api.PhaseCurrents
 			api.PhaseSwitcher
 		}{
 			CfosPowerBrain: base,
+			MeterEnergy: &decorateCfosMeterEnergyImpl{
+				meterEnergy: meterEnergy,
+			},
+			PhaseCurrents: &decorateCfosPhaseCurrentsImpl{
+				phaseCurrents: phaseCurrents,
+			},
+			PhaseSwitcher: &decorateCfosPhaseSwitcherImpl{
+				phaseSwitcher: phaseSwitcher,
+			},
+		}
+
+	case meter != nil && meterEnergy != nil && phaseCurrents != nil && phaseSwitcher != nil:
+		return &struct {
+			*CfosPowerBrain
+			api.Meter
+			api.MeterEnergy
+			api.PhaseCurrents
+			api.PhaseSwitcher
+		}{
+			CfosPowerBrain: base,
+			Meter: &decorateCfosMeterImpl{
+				meter: meter,
+			},
+			MeterEnergy: &decorateCfosMeterEnergyImpl{
+				meterEnergy: meterEnergy,
+			},
+			PhaseCurrents: &decorateCfosPhaseCurrentsImpl{
+				phaseCurrents: phaseCurrents,
+			},
 			PhaseSwitcher: &decorateCfosPhaseSwitcherImpl{
 				phaseSwitcher: phaseSwitcher,
 			},
@@ -26,6 +248,30 @@ func decorateCfos(base *CfosPowerBrain, phaseSwitcher func(int) error) api.Charg
 	return nil
 }
 
+type decorateCfosMeterImpl struct {
+	meter func() (float64, error)
+}
+
+func (impl *decorateCfosMeterImpl) CurrentPower() (float64, error) {
+	return impl.meter()
+}
+
+type decorateCfosMeterEnergyImpl struct {
+	meterEnergy func() (float64, error)
+}
+
+func (impl *decorateCfosMeterEnergyImpl) TotalEnergy() (float64, error) {
+	return impl.meterEnergy()
+}
+
+type decorateCfosPhaseCurrentsImpl struct {
+	phaseCurrents func() (float64, float64, float64, error)
+}
+
+func (impl *decorateCfosPhaseCurrentsImpl) Currents() (float64, float64, float64, error) {
+	return impl.phaseCurrents()
+}
+
 type decorateCfosPhaseSwitcherImpl struct {
 	phaseSwitcher func(int) error
 }
diff --git a/charger/config.go b/charger/config.go
index 0c0617ea..2dcc7e68 100644
--- a/charger/config.go
+++ b/charger/config.go
@@ -1,31 +1,21 @@
 package charger
 
 import (
+	"context"
 	"fmt"
 	"strings"
 
 	"github.com/evcc-io/evcc/api"
+	reg "github.com/evcc-io/evcc/util/registry"
 )
 
-type chargerRegistry map[string]func(map[string]interface{}) (api.Charger, error)
+var registry = reg.New[api.Charger]("charger")
 
-func (r chargerRegistry) Add(name string, factory func(map[string]interface{}) (api.Charger, error)) {
-	if _, exists := r[name]; exists {
-		panic(fmt.Sprintf("cannot register duplicate charger type: %s", name))
-	}
-	r[name] = factory
+// Types returns the list of types
+func Types() []string {
+	return registry.Types()
 }
 
-func (r chargerRegistry) Get(name string) (func(map[string]interface{}) (api.Charger, error), error) {
-	factory, exists := r[name]
-	if !exists {
-		return nil, fmt.Errorf("invalid charger type: %s", name)
-	}
-	return factory, nil
-}
-
-var registry chargerRegistry = make(map[string]func(map[string]interface{}) (api.Charger, error))
-
 // NewFromConfig creates charger from configuration
 func NewFromConfig(typ string, other map[string]interface{}) (api.Charger, error) {
 	factory, err := registry.Get(strings.ToLower(typ))
@@ -33,7 +23,7 @@ func NewFromConfig(typ string, other map[string]interface{}) (api.Charger, error
 		return nil, err
 	}
 
-	v, err := factory(other)
+	v, err := factory(context.TODO(), other)
 	if err != nil {
 		err = fmt.Errorf("cannot create charger type '%s': %w", typ, err)
 	}
diff --git a/charger/delta.go b/charger/delta.go
index 68005f96..42371ee6 100644
--- a/charger/delta.go
+++ b/charger/delta.go
@@ -76,7 +76,7 @@ func NewDeltaFromConfig(other map[string]interface{}) (api.Charger, error) {
 		return nil, err
 	}
 
-	return NewDelta(cc.URI, cc.Device, cc.Comset, cc.Baudrate, modbus.ProtocolFromRTU(cc.RTU), cc.ID, cc.Connector)
+	return NewDelta(cc.URI, cc.Device, cc.Comset, cc.Baudrate, cc.Settings.Protocol(), cc.ID, cc.Connector)
 }
 
 // NewDelta creates Delta charger
diff --git a/charger/evsedin.go b/charger/evsedin.go
index 049b4e9d..277cc8f0 100644
--- a/charger/evsedin.go
+++ b/charger/evsedin.go
@@ -39,7 +39,7 @@ func NewEvseDINFromConfig(other map[string]interface{}) (api.Charger, error) {
 		return nil, err
 	}
 
-	return NewEvseDIN(cc.URI, cc.Device, cc.Comset, cc.Baudrate, modbus.ProtocolFromRTU(cc.RTU), cc.ID)
+	return NewEvseDIN(cc.URI, cc.Device, cc.Comset, cc.Baudrate, cc.Protocol(), cc.ID)
 }
 
 // NewEvseDIN creates EVSE DIN charger
diff --git a/charger/heidelberg-ec.go b/charger/heidelberg-ec.go
index 1a3c8829..f0ce71e4 100644
--- a/charger/heidelberg-ec.go
+++ b/charger/heidelberg-ec.go
@@ -70,7 +70,7 @@ func NewHeidelbergECFromConfig(other map[string]interface{}) (api.Charger, error
 		return nil, err
 	}
 
-	return NewHeidelbergEC(cc.URI, cc.Device, cc.Comset, cc.Baudrate, modbus.ProtocolFromRTU(cc.RTU), cc.ID)
+	return NewHeidelbergEC(cc.URI, cc.Device, cc.Comset, cc.Baudrate, cc.Protocol(), cc.ID)
 }
 
 // NewHeidelbergEC creates HeidelbergEC charger
diff --git a/charger/mennekes-compact.go b/charger/mennekes-compact.go
index 36415328..d334ab35 100644
--- a/charger/mennekes-compact.go
+++ b/charger/mennekes-compact.go
@@ -83,7 +83,7 @@ func NewMennekesCompactFromConfig(other map[string]interface{}) (api.Charger, er
 		return nil, err
 	}
 
-	return NewMennekesCompact(cc.URI, cc.Device, cc.Comset, cc.Baudrate, modbus.ProtocolFromRTU(cc.RTU), cc.ID, cc.Timeout)
+	return NewMennekesCompact(cc.URI, cc.Device, cc.Comset, cc.Baudrate, cc.Settings.Protocol(), cc.ID, cc.Timeout)
 }
 
 // NewMennekesCompact creates Mennekes charger
diff --git a/charger/obo.go b/charger/obo.go
index 6b3c0342..314adda8 100644
--- a/charger/obo.go
+++ b/charger/obo.go
@@ -39,7 +39,7 @@ func NewOboFromConfig(other map[string]interface{}) (api.Charger, error) {
 		return nil, err
 	}
 
-	return NewObo(cc.URI, cc.Device, cc.Comset, cc.Baudrate, modbus.ProtocolFromRTU(cc.RTU), cc.ID)
+	return NewObo(cc.URI, cc.Device, cc.Comset, cc.Baudrate, cc.Protocol(), cc.ID)
 }
 
 // NewObo creates OBO Bettermann charger
diff --git a/charger/ocpp.go b/charger/ocpp.go
index d31802d1..f65798e4 100644
--- a/charger/ocpp.go
+++ b/charger/ocpp.go
@@ -26,12 +26,14 @@ type OCPP struct {
 	idtag             string
 	phases            int
 	current           float64
+	enabled           bool
 	meterValuesSample string
 	timeout           time.Duration
 	phaseSwitching    bool
 	remoteStart       bool
 	chargingRateUnit  types.ChargingRateUnitType
 	lp                loadpoint.API
+	bootNotification  *core.BootNotificationRequest
 }
 
 const defaultIdTag = "evcc" // RemoteStartTransaction only
@@ -90,12 +92,12 @@ func NewOCPPFromConfig(other map[string]interface{}) (api.Charger, error) {
 	}
 
 	var currentsG func() (float64, float64, float64, error)
-	if c.hasMeasurement(types.MeasurandCurrentImport + ".L3") {
+	if c.hasMeasurement(types.MeasurandCurrentImport) {
 		currentsG = c.currents
 	}
 
 	var voltagesG func() (float64, float64, float64, error)
-	if c.hasMeasurement(types.MeasurandVoltage + ".L3") {
+	if c.hasMeasurement(types.MeasurandVoltage) {
 		voltagesG = c.voltages
 	}
 
@@ -168,6 +170,12 @@ func NewOCPP(id string, connector int, idtag string,
 	// see who's there
 	if boot {
 		conn.TriggerMessageRequest(core.BootNotificationFeatureName)
+		select {
+		case <-time.After(timeout):
+			c.log.WARN.Printf("boot notification timeout")
+		case res := <-cp.BootNotificationRequest():
+			c.bootNotification = res
+		}
 	}
 
 	var (
@@ -278,6 +286,13 @@ func NewOCPP(id string, connector int, idtag string,
 	if c.hasMeasurement(types.MeasurandPowerActiveImport) || c.hasMeasurement(types.MeasurandEnergyActiveImportRegister) {
 		conn.TriggerMessageRequest(core.MeterValuesFeatureName)
 
+		// wait for meter values
+		select {
+		case <-time.After(timeout):
+			c.log.WARN.Println("meter value timeout")
+		case <-c.conn.MeterSampled():
+		}
+
 		if meterInterval > 0 && meterInterval != meterSampleInterval {
 			if err := c.configure(ocpp.KeyMeterValueSampleInterval, strconv.Itoa(int(meterInterval.Seconds()))); err != nil {
 				return nil, err
@@ -330,50 +345,100 @@ func (c *OCPP) configure(key, val string) error {
 
 // wait waits for a CP roundtrip with timeout
 func (c *OCPP) wait(err error, rc chan error) error {
-	if err == nil {
-		select {
-		case err = <-rc:
-			close(rc)
-		case <-time.After(c.timeout):
-			err = api.ErrTimeout
-		}
-	}
-	return err
+	return ocpp.Wait(err, rc, c.timeout)
 }
 
 // Status implements the api.Charger interface
 func (c *OCPP) Status() (api.ChargeStatus, error) {
-	if c.remoteStart {
-		needtxn, err := c.conn.NeedsTransaction()
-		if err != nil {
-			return api.StatusNone, err
-		}
+	status, err := c.conn.Status()
+	if err != nil {
+		return api.StatusNone, err
+	}
 
-		if needtxn {
+	if c.conn.NeedsAuthentication() {
+		if c.remoteStart {
 			// lock the cable by starting remote transaction after vehicle connected
 			if err := c.initTransaction(); err != nil {
-				return api.StatusNone, err
+				c.log.WARN.Printf("failed to start remote transaction: %v", err)
 			}
+		} else {
+			// TODO: bring this status to UI
+			c.log.WARN.Printf("waiting for local authentication")
 		}
 	}
 
-	return c.conn.Status()
+	switch status {
+	case
+		core.ChargePointStatusAvailable,   // "Available"
+		core.ChargePointStatusUnavailable: // "Unavailable"
+		return api.StatusA, nil
+	case
+		core.ChargePointStatusPreparing,     // "Preparing"
+		core.ChargePointStatusSuspendedEVSE, // "SuspendedEVSE"
+		core.ChargePointStatusSuspendedEV,   // "SuspendedEV"
+		core.ChargePointStatusFinishing:     // "Finishing"
+		return api.StatusB, nil
+	case
+		core.ChargePointStatusCharging: // "Charging"
+		return api.StatusC, nil
+	case
+		core.ChargePointStatusReserved, // "Reserved"
+		core.ChargePointStatusFaulted:  // "Faulted"
+		return api.StatusF, fmt.Errorf("chargepoint status: %s", status)
+	default:
+		return api.StatusNone, fmt.Errorf("invalid chargepoint status: %s", status)
+	}
 }
 
 // Enabled implements the api.Charger interface
 func (c *OCPP) Enabled() (bool, error) {
-	current, err := c.getCurrent()
+	if s, err := c.conn.Status(); err == nil {
+		switch s {
+		case
+			core.ChargePointStatusSuspendedEVSE:
+			return false, nil
+		case
+			core.ChargePointStatusCharging,
+			core.ChargePointStatusSuspendedEV:
+			return true, nil
+		}
+	}
+
+	// fallback to the "offered" measurands
+	if c.hasMeasurement(types.MeasurandCurrentOffered) {
+		if v, err := c.getMaxCurrent(); err == nil {
+			return v > 0, nil
+		}
+	}
+	if c.hasMeasurement(types.MeasurandPowerOffered) {
+		if v, err := c.getMaxPower(); err == nil {
+			return v > 0, err
+		}
+	}
 
-	return current > 0, err
+	// fallback to querying the active charging profile schedule limit
+	if v, err := c.getScheduleLimit(); err == nil {
+		return v > 0, nil
+	}
+
+	// fallback to cached value as last resort
+	return c.enabled, nil
 }
 
+// Enable implements the api.Charger interface
 func (c *OCPP) Enable(enable bool) error {
 	var current float64
 	if enable {
 		current = c.current
 	}
 
-	return c.setCurrent(current)
+	err := c.setCurrent(current)
+	if err == nil {
+		// cache enabled state as last fallback option
+		c.enabled = enable
+	}
+
+	return err
 }
 
 func (c *OCPP) initTransaction() error {
@@ -415,15 +480,12 @@ func (c *OCPP) setCurrent(current float64) error {
 	return err
 }
 
-// getCurrent returns the internal current offered by the chargepoint
-func (c *OCPP) getCurrent() (float64, error) {
-	var current float64
+// getScheduleLimit queries the current or power limit the charge point is currently set to offer
+func (c *OCPP) getScheduleLimit() (float64, error) {
+	const duration = 60 // duration of requested schedule in seconds
 
-	if c.hasMeasurement(types.MeasurandCurrentOffered) {
-		return c.getMaxCurrent()
-	}
+	var limit float64
 
-	// fallback to GetCompositeSchedule request
 	rc := make(chan error, 1)
 	err := ocpp.Instance().GetCompositeSchedule(c.conn.ChargePoint().ID(), func(resp *smartcharging.GetCompositeScheduleConfirmation, err error) {
 		if err == nil && resp != nil && resp.Status != smartcharging.GetCompositeScheduleStatusAccepted {
@@ -432,20 +494,22 @@ func (c *OCPP) getCurrent() (float64, error) {
 
 		if err == nil {
 			if resp.ChargingSchedule != nil && len(resp.ChargingSchedule.ChargingSchedulePeriod) > 0 {
-				current = resp.ChargingSchedule.ChargingSchedulePeriod[0].Limit
+				// return first (current) period limit
+				limit = resp.ChargingSchedule.ChargingSchedulePeriod[0].Limit
 			} else {
 				err = fmt.Errorf("invalid ChargingSchedule")
 			}
 		}
 
 		rc <- err
-	}, c.conn.ID(), 1)
+	}, c.conn.ID(), duration)
 
 	err = c.wait(err, rc)
 
-	return current, err
+	return limit, err
 }
 
+// createTxDefaultChargingProfile returns a TxDefaultChargingProfile with given current
 func (c *OCPP) createTxDefaultChargingProfile(current float64) *types.ChargingProfile {
 	phases := c.phases
 	period := types.NewChargingSchedulePeriod(0, current)
@@ -466,11 +530,12 @@ func (c *OCPP) createTxDefaultChargingProfile(current float64) *types.ChargingPr
 	}
 
 	return &types.ChargingProfile{
-		ChargingProfileId:      0,
+		ChargingProfileId:      1,
 		StackLevel:             0,
 		ChargingProfilePurpose: types.ChargingProfilePurposeTxDefaultProfile,
-		ChargingProfileKind:    types.ChargingProfileKindRelative,
+		ChargingProfileKind:    types.ChargingProfileKindAbsolute,
 		ChargingSchedule: &types.ChargingSchedule{
+			StartSchedule:          types.Now(),
 			ChargingRateUnit:       c.chargingRateUnit,
 			ChargingSchedulePeriod: []types.ChargingSchedulePeriod{period},
 		},
@@ -498,6 +563,10 @@ func (c *OCPP) getMaxCurrent() (float64, error) {
 	return c.conn.GetMaxCurrent()
 }
 
+func (c *OCPP) getMaxPower() (float64, error) {
+	return c.conn.GetMaxPower()
+}
+
 // currentPower implements the api.Meter interface
 func (c *OCPP) currentPower() (float64, error) {
 	return c.conn.CurrentPower()
@@ -537,6 +606,45 @@ func (c *OCPP) Identify() (string, error) {
 	return c.conn.IdTag(), nil
 }
 
+var _ api.Diagnosis = (*OCPP)(nil)
+
+// Diagnose implements the api.Diagnosis interface
+func (c *OCPP) Diagnose() {
+	fmt.Printf("\tCharge Point ID: %s\n", c.conn.ChargePoint().ID())
+
+	if c.bootNotification != nil {
+		fmt.Printf("\tBoot Notification:\n")
+		fmt.Printf("\t\tChargePointVendor: %s\n", c.bootNotification.ChargePointVendor)
+		fmt.Printf("\t\tChargePointModel: %s\n", c.bootNotification.ChargePointModel)
+		fmt.Printf("\t\tChargePointSerialNumber: %s\n", c.bootNotification.ChargePointSerialNumber)
+		fmt.Printf("\t\tFirmwareVersion: %s\n", c.bootNotification.FirmwareVersion)
+	}
+
+	fmt.Printf("\tConfiguration:\n")
+	rc := make(chan error, 1)
+	err := ocpp.Instance().GetConfiguration(c.conn.ChargePoint().ID(), func(resp *core.GetConfigurationConfirmation, err error) {
+		if err == nil {
+			// sort configuration keys for printing
+			slices.SortFunc(resp.ConfigurationKey, func(i, j core.ConfigurationKey) int {
+				return cmp.Compare(i.Key, j.Key)
+			})
+
+			rw := map[bool]string{false: "r/w", true: "r/o"}
+
+			for _, opt := range resp.ConfigurationKey {
+				if opt.Value == nil {
+					continue
+				}
+
+				fmt.Printf("\t\t%s (%s): %s\n", opt.Key, rw[opt.Readonly], *opt.Value)
+			}
+		}
+
+		rc <- err
+	}, nil)
+	c.wait(err, rc)
+}
+
 var _ loadpoint.Controller = (*OCPP)(nil)
 
 // LoadpointControl implements loadpoint.Controller
diff --git a/charger/ocpp/connector.go b/charger/ocpp/connector.go
index 8c4459aa..363eac0b 100644
--- a/charger/ocpp/connector.go
+++ b/charger/ocpp/connector.go
@@ -24,6 +24,7 @@ type Connector struct {
 
 	status  *core.StatusNotificationRequest
 	statusC chan struct{}
+	meterC  chan map[types.Measurand]types.SampledValue
 
 	meterUpdated time.Time
 	measurements map[types.Measurand]types.SampledValue
@@ -42,6 +43,7 @@ func NewConnector(log *util.Logger, id int, cp *CP, timeout time.Duration) (*Con
 		clock:        clock.New(),
 		statusC:      make(chan struct{}),
 		measurements: make(map[types.Measurand]types.SampledValue),
+		meterC:       make(chan map[types.Measurand]types.SampledValue),
 		timeout:      timeout,
 	}
 
@@ -54,6 +56,10 @@ func (conn *Connector) TestClock(clock clock.Clock) {
 	conn.clock = clock
 }
 
+func (conn *Connector) MeterSampled() <-chan map[types.Measurand]types.SampledValue {
+	return conn.meterC
+}
+
 func (conn *Connector) ChargePoint() *CP {
 	return conn.cp
 }
@@ -68,8 +74,8 @@ func (conn *Connector) IdTag() string {
 	return conn.idTag
 }
 
-func (conn *Connector) TriggerMessageRequest(feature remotetrigger.MessageTrigger, f ...func(request *remotetrigger.TriggerMessageRequest)) {
-	Instance().TriggerMessageRequest(conn.cp.ID(), feature, func(request *remotetrigger.TriggerMessageRequest) {
+func (conn *Connector) TriggerMessageRequest(feature remotetrigger.MessageTrigger, f ...func(request *remotetrigger.TriggerMessageRequest)) error {
+	return Instance().TriggerMessageRequest(conn.cp.ID(), feature, func(request *remotetrigger.TriggerMessageRequest) {
 		request.ConnectorId = &conn.id
 		for _, f := range f {
 			f(request)
@@ -122,52 +128,36 @@ func (conn *Connector) TransactionID() (int, error) {
 	return conn.txnId, nil
 }
 
-func (conn *Connector) Status() (api.ChargeStatus, error) {
+// Status returns the unmapped charge point status
+func (conn *Connector) Status() (core.ChargePointStatus, error) {
+	if !conn.cp.Connected() {
+		return "", api.ErrTimeout
+	}
+
 	conn.mu.Lock()
 	defer conn.mu.Unlock()
 
-	res := api.StatusNone
-
-	if !conn.cp.Connected() {
-		return res, api.ErrTimeout
+	if conn.status == nil {
+		return core.ChargePointStatusUnavailable, nil
 	}
 
 	if conn.status.ErrorCode != core.NoError {
-		return res, fmt.Errorf("%s: %s", conn.status.ErrorCode, conn.status.Info)
-	}
-
-	switch conn.status.Status {
-	case core.ChargePointStatusAvailable, // "Available"
-		core.ChargePointStatusUnavailable: // "Unavailable"
-		res = api.StatusA
-	case
-		core.ChargePointStatusPreparing,     // "Preparing"
-		core.ChargePointStatusSuspendedEVSE, // "SuspendedEVSE"
-		core.ChargePointStatusSuspendedEV,   // "SuspendedEV"
-		core.ChargePointStatusFinishing:     // "Finishing"
-		res = api.StatusB
-	case core.ChargePointStatusCharging: // "Charging"
-		res = api.StatusC
-	case core.ChargePointStatusReserved, // "Reserved"
-		core.ChargePointStatusFaulted: // "Faulted"
-		return api.StatusF, fmt.Errorf("chargepoint status: %s", conn.status.ErrorCode)
-	default:
-		return api.StatusNone, fmt.Errorf("invalid chargepoint status: %s", conn.status.Status)
+		return "", fmt.Errorf("%s: %s", conn.status.ErrorCode, conn.status.Info)
 	}
 
-	return res, nil
+	return conn.status.Status, nil
 }
 
-// NeedsTransaction checks if an initial RemoteStart of a transaction is required
-func (conn *Connector) NeedsTransaction() (bool, error) {
+// NeedsAuthentication checks if local authentication or an initial RemoteStartTransaction is required
+func (conn *Connector) NeedsAuthentication() bool {
 	if !conn.cp.Connected() {
-		return false, api.ErrTimeout
+		return false
 	}
 
 	conn.mu.Lock()
 	defer conn.mu.Unlock()
 
-	return conn.txnId == 0 && conn.status.Status == core.ChargePointStatusPreparing, nil
+	return conn.status != nil && conn.txnId == 0 && conn.status.Status == core.ChargePointStatusPreparing
 }
 
 // isMeterTimeout checks if meter values are outdated.
@@ -176,6 +166,9 @@ func (conn *Connector) isMeterTimeout() bool {
 	return conn.timeout > 0 && conn.clock.Since(conn.meterUpdated) > conn.timeout
 }
 
+var _ api.CurrentGetter = (*Connector)(nil)
+
+// GetMaxCurrent returns the maximum phase current the charge point is set to offer
 func (conn *Connector) GetMaxCurrent() (float64, error) {
 	if !conn.cp.Connected() {
 		return 0, api.ErrTimeout
@@ -197,6 +190,28 @@ func (conn *Connector) GetMaxCurrent() (float64, error) {
 	return 0, api.ErrNotAvailable
 }
 
+// GetMaxPower returns the maximum power the charge point is set to offer
+func (conn *Connector) GetMaxPower() (float64, error) {
+	if !conn.cp.Connected() {
+		return 0, api.ErrTimeout
+	}
+
+	conn.mu.Lock()
+	defer conn.mu.Unlock()
+
+	// fallthrough for last value on timeout when no transaction is running
+	if conn.txnId != 0 && conn.isMeterTimeout() {
+		return 0, api.ErrTimeout
+	}
+
+	if m, ok := conn.measurements[types.MeasurandPowerOffered]; ok {
+		f, err := strconv.ParseFloat(m.Value, 64)
+		return scale(f, m.Unit), err
+	}
+
+	return 0, api.ErrNotAvailable
+}
+
 var _ api.Meter = (*Connector)(nil)
 
 func (conn *Connector) CurrentPower() (float64, error) {
@@ -224,8 +239,6 @@ func (conn *Connector) CurrentPower() (float64, error) {
 	return 0, api.ErrNotAvailable
 }
 
-var _ api.MeterEnergy = (*Connector)(nil)
-
 func (conn *Connector) TotalEnergy() (float64, error) {
 	if !conn.cp.Connected() {
 		return 0, api.ErrTimeout
@@ -261,8 +274,7 @@ func (conn *Connector) Soc() (float64, error) {
 	}
 
 	if m, ok := conn.measurements[types.MeasurandSoC]; ok {
-		f, err := strconv.ParseFloat(m.Value, 64)
-		return scale(f, m.Unit) / 1e3, err
+		return strconv.ParseFloat(m.Value, 64)
 	}
 
 	return 0, api.ErrNotAvailable
@@ -280,11 +292,9 @@ func scale(f float64, scale types.UnitOfMeasure) float64 {
 }
 
 func getPhaseKey(key types.Measurand, phase int) types.Measurand {
-	return key + types.Measurand("@L"+strconv.Itoa(phase))
+	return key + types.Measurand(".L"+strconv.Itoa(phase))
 }
 
-var _ api.PhaseCurrents = (*Connector)(nil)
-
 func (conn *Connector) Currents() (float64, float64, float64, error) {
 	if !conn.cp.Connected() {
 		return 0, 0, 0, api.ErrTimeout
@@ -337,9 +347,13 @@ func (conn *Connector) Voltages() (float64, float64, float64, error) {
 	voltages := make([]float64, 0, 3)
 
 	for phase := 1; phase <= 3; phase++ {
-		m, ok := conn.measurements[getPhaseKey(types.MeasurandVoltage, phase)]
+		m, ok := conn.measurements[getPhaseKey(types.MeasurandVoltage, phase)+"-N"]
 		if !ok {
-			return 0, 0, 0, api.ErrNotAvailable
+			// fallback for wrong voltage phase labeling
+			m, ok = conn.measurements[getPhaseKey(types.MeasurandVoltage, phase)]
+			if !ok {
+				return 0, 0, 0, api.ErrNotAvailable
+			}
 		}
 
 		f, err := strconv.ParseFloat(m.Value, 64)
diff --git a/charger/ocpp/connector_core.go b/charger/ocpp/connector_core.go
index c851a6e3..0f0e0f9e 100644
--- a/charger/ocpp/connector_core.go
+++ b/charger/ocpp/connector_core.go
@@ -42,7 +42,7 @@ func (conn *Connector) StatusNotification(request *core.StatusNotificationReques
 
 func getSampleKey(s types.SampledValue) types.Measurand {
 	if s.Phase != "" {
-		return s.Measurand + types.Measurand("@"+string(s.Phase))
+		return s.Measurand + types.Measurand("."+string(s.Phase))
 	}
 
 	return s.Measurand
@@ -71,6 +71,11 @@ func (conn *Connector) MeterValues(request *core.MeterValuesRequest) (*core.Mete
 		}
 	}
 
+	select {
+	case conn.meterC <- conn.measurements:
+	default:
+	}
+
 	return new(core.MeterValuesConfirmation), nil
 }
 
diff --git a/charger/ocpp/cp.go b/charger/ocpp/cp.go
index af5faadb..05621d58 100644
--- a/charger/ocpp/cp.go
+++ b/charger/ocpp/cp.go
@@ -5,15 +5,19 @@ import (
 	"sync"
 
 	"github.com/evcc-io/evcc/util"
+	"github.com/lorenzodonini/ocpp-go/ocpp1.6/core"
 )
 
 // TODO support multiple connectors
 // Since ocpp-go interfaces at charge point level, we need to manage multiple connector separately
 
 type CP struct {
-	mu   sync.RWMutex
-	once sync.Once
-	log  *util.Logger
+	mu          sync.RWMutex
+	log         *util.Logger
+	onceConnect sync.Once
+	onceBoot    sync.Once
+
+	bootNotificationRequestC chan *core.BootNotificationRequest
 
 	id string
 
@@ -30,6 +34,8 @@ func NewChargePoint(log *util.Logger, id string) *CP {
 
 		connectC:   make(chan struct{}),
 		connectors: make(map[int]*Connector),
+
+		bootNotificationRequestC: make(chan *core.BootNotificationRequest, 1),
 	}
 }
 
@@ -65,6 +71,10 @@ func (cp *CP) connectorByTransactionID(id int) *Connector {
 	return nil
 }
 
+func (cp *CP) BootNotificationRequest() <-chan *core.BootNotificationRequest {
+	return cp.bootNotificationRequestC
+}
+
 func (cp *CP) ID() string {
 	cp.mu.RLock()
 	defer cp.mu.RUnlock()
@@ -90,7 +100,7 @@ func (cp *CP) connect(connect bool) {
 	cp.connected = connect
 
 	if connect {
-		cp.once.Do(func() {
+		cp.onceConnect.Do(func() {
 			close(cp.connectC)
 		})
 	}
diff --git a/charger/ocpp/cp_core.go b/charger/ocpp/cp_core.go
index d0d15a3b..f913e271 100644
--- a/charger/ocpp/cp_core.go
+++ b/charger/ocpp/cp_core.go
@@ -38,6 +38,10 @@ func (cp *CP) BootNotification(request *core.BootNotificationRequest) (*core.Boo
 		Status:      core.RegistrationStatusAccepted,
 	}
 
+	cp.onceBoot.Do(func() {
+		cp.bootNotificationRequestC <- request
+	})
+
 	return res, nil
 }
 
diff --git a/charger/ocpp/cs.go b/charger/ocpp/cs.go
index 70e0d198..84f8ff41 100644
--- a/charger/ocpp/cs.go
+++ b/charger/ocpp/cs.go
@@ -4,6 +4,7 @@ import (
 	"errors"
 	"fmt"
 	"sync"
+	"time"
 
 	"github.com/evcc-io/evcc/util"
 	ocpp16 "github.com/lorenzodonini/ocpp-go/ocpp1.6"
@@ -13,7 +14,8 @@ type CS struct {
 	mu  sync.Mutex
 	log *util.Logger
 	ocpp16.CentralSystem
-	cps map[string]*CP
+	cps     map[string]*CP
+	timeout time.Duration
 }
 
 // Register registers a charge point with the central system.
diff --git a/charger/ocpp/cs_core.go b/charger/ocpp/cs_core.go
index af7c82c4..593640bc 100644
--- a/charger/ocpp/cs_core.go
+++ b/charger/ocpp/cs_core.go
@@ -1,6 +1,8 @@
 package ocpp
 
 import (
+	"errors"
+
 	"github.com/lorenzodonini/ocpp-go/ocpp1.6/core"
 	"github.com/lorenzodonini/ocpp-go/ocpp1.6/firmware"
 	"github.com/lorenzodonini/ocpp-go/ocpp1.6/remotetrigger"
@@ -8,24 +10,32 @@ import (
 
 // cs actions
 
-func (cs *CS) TriggerResetRequest(id string, resetType core.ResetType) {
-	if err := cs.Reset(id, func(request *core.ResetConfirmation, err error) {
+func (cs *CS) TriggerResetRequest(id string, resetType core.ResetType) error {
+	rc := make(chan error, 1)
+
+	err := cs.Reset(id, func(request *core.ResetConfirmation, err error) {
 		if err == nil && request != nil && request.Status != core.ResetStatusAccepted {
-			cs.log.ERROR.Printf("TriggerReset for %s: %+v", id, request.Status)
+			err = errors.New(string(request.Status))
 		}
-	}, resetType); err != nil {
-		cs.log.ERROR.Printf("send TriggerReset for %s failed: %v", id, err)
-	}
+
+		rc <- err
+	}, resetType)
+
+	return Wait(err, rc, cs.timeout)
 }
 
-func (cs *CS) TriggerMessageRequest(id string, requestedMessage remotetrigger.MessageTrigger, props ...func(request *remotetrigger.TriggerMessageRequest)) {
-	if err := cs.TriggerMessage(id, func(request *remotetrigger.TriggerMessageConfirmation, err error) {
+func (cs *CS) TriggerMessageRequest(id string, requestedMessage remotetrigger.MessageTrigger, props ...func(request *remotetrigger.TriggerMessageRequest)) error {
+	rc := make(chan error, 1)
+
+	err := cs.TriggerMessage(id, func(request *remotetrigger.TriggerMessageConfirmation, err error) {
 		if err == nil && request != nil && request.Status != remotetrigger.TriggerMessageStatusAccepted {
-			cs.log.ERROR.Printf("TriggerMessage %s for %s: %+v", requestedMessage, id, request.Status)
+			err = errors.New(string(request.Status))
 		}
-	}, requestedMessage, props...); err != nil {
-		cs.log.ERROR.Printf("send TriggerMessage %s for %s failed: %v", requestedMessage, id, err)
-	}
+
+		rc <- err
+	}, requestedMessage, props...)
+
+	return Wait(err, rc, cs.timeout)
 }
 
 // cp actions
diff --git a/charger/ocpp/helper.go b/charger/ocpp/helper.go
new file mode 100644
index 00000000..d774c741
--- /dev/null
+++ b/charger/ocpp/helper.go
@@ -0,0 +1,20 @@
+package ocpp
+
+import (
+	"time"
+
+	"github.com/evcc-io/evcc/api"
+)
+
+// Wait waits for a CP roundtrip with timeout
+func Wait(err error, rc chan error, timeout time.Duration) error {
+	if err == nil {
+		select {
+		case err = <-rc:
+			close(rc)
+		case <-time.After(timeout):
+			err = api.ErrTimeout
+		}
+	}
+	return err
+}
diff --git a/charger/ocpp/instance.go b/charger/ocpp/instance.go
index 9df9ec1e..a78a5a58 100644
--- a/charger/ocpp/instance.go
+++ b/charger/ocpp/instance.go
@@ -9,10 +9,7 @@ import (
 	"github.com/lorenzodonini/ocpp-go/ocpp"
 	ocpp16 "github.com/lorenzodonini/ocpp-go/ocpp1.6"
 	"github.com/lorenzodonini/ocpp-go/ocpp1.6/core"
-	"github.com/lorenzodonini/ocpp-go/ocpp1.6/firmware"
-	"github.com/lorenzodonini/ocpp-go/ocpp1.6/localauth"
 	"github.com/lorenzodonini/ocpp-go/ocpp1.6/remotetrigger"
-	"github.com/lorenzodonini/ocpp-go/ocpp1.6/reservation"
 	"github.com/lorenzodonini/ocpp-go/ocpp1.6/smartcharging"
 	"github.com/lorenzodonini/ocpp-go/ocppj"
 	"github.com/lorenzodonini/ocpp-go/ws"
@@ -37,7 +34,7 @@ func Instance() *CS {
 		dispatcher := ocppj.NewDefaultServerDispatcher(ocppj.NewFIFOQueueMap(0))
 		dispatcher.SetTimeout(time.Minute)
 
-		endpoint := ocppj.NewServer(server, dispatcher, nil, core.Profile, localauth.Profile, firmware.Profile, reservation.Profile, remotetrigger.Profile, smartcharging.Profile)
+		endpoint := ocppj.NewServer(server, dispatcher, nil, core.Profile, remotetrigger.Profile, smartcharging.Profile)
 		endpoint.SetInvalidMessageHook(func(client ws.Channel, err *ocpp.Error, rawMessage string, parsedFields []interface{}) *ocpp.Error {
 			log.ERROR.Printf("%v (%s)", err, rawMessage)
 			return nil
@@ -49,6 +46,7 @@ func Instance() *CS {
 			log:           log,
 			cps:           make(map[string]*CP),
 			CentralSystem: cs,
+			timeout:       30 * time.Second,
 		}
 
 		ocppj.SetLogger(instance)
@@ -56,7 +54,6 @@ func Instance() *CS {
 		cs.SetCoreHandler(instance)
 		cs.SetNewChargePointHandler(instance.NewChargePoint)
 		cs.SetChargePointDisconnectedHandler(instance.ChargePointDisconnected)
-		cs.SetFirmwareManagementHandler(instance)
 
 		go instance.errorHandler(cs.Errors())
 		go cs.Start(8887, "/{ws}")
diff --git a/charger/phoenix-ev-ser.go b/charger/phoenix-ev-ser.go
index e612fd58..3e5366d1 100644
--- a/charger/phoenix-ev-ser.go
+++ b/charger/phoenix-ev-ser.go
@@ -34,7 +34,7 @@ func NewPhoenixEVSerFromConfig(other map[string]interface{}) (api.Charger, error
 		return nil, err
 	}
 
-	return NewPhoenixEVSer(cc.URI, cc.Device, cc.Comset, cc.Baudrate, modbus.ProtocolFromRTU(cc.RTU), cc.ID)
+	return NewPhoenixEVSer(cc.URI, cc.Device, cc.Comset, cc.Baudrate, cc.Protocol(), cc.ID)
 }
 
 // NewPhoenixEVSer creates a Phoenix charger
diff --git a/charger/prachtalpha.go b/charger/prachtalpha.go
index 989c17d8..6bdeb75e 100644
--- a/charger/prachtalpha.go
+++ b/charger/prachtalpha.go
@@ -66,7 +66,7 @@ func NewPrachtAlphaFromConfig(other map[string]interface{}) (api.Charger, error)
 		return nil, err
 	}
 
-	return NewPrachtAlpha(cc.URI, cc.Device, cc.Comset, cc.Baudrate, modbus.ProtocolFromRTU(cc.RTU), cc.ID, cc.Timeout, cc.Connector)
+	return NewPrachtAlpha(cc.URI, cc.Device, cc.Comset, cc.Baudrate, cc.Settings.Protocol(), cc.ID, cc.Timeout, cc.Connector)
 }
 
 // NewPrachtAlpha creates PrachtAlpha charger
diff --git a/charger/pulsares.go b/charger/pulsares.go
index be66ed37..73122eca 100644
--- a/charger/pulsares.go
+++ b/charger/pulsares.go
@@ -62,7 +62,7 @@ func NewPulsaresFromConfig(other map[string]interface{}) (api.Charger, error) {
 		return nil, err
 	}
 
-	wb, err := NewPulsares(cc.URI, cc.Device, cc.Comset, cc.Baudrate, modbus.ProtocolFromRTU(cc.RTU), cc.ID)
+	wb, err := NewPulsares(cc.URI, cc.Device, cc.Comset, cc.Baudrate, cc.Protocol(), cc.ID)
 	if err != nil {
 		return nil, err
 	}
diff --git a/charger/smartevse.go b/charger/smartevse.go
index 91e286a6..d8e9c717 100644
--- a/charger/smartevse.go
+++ b/charger/smartevse.go
@@ -77,7 +77,7 @@ func NewsmartEVSEFromConfig(other map[string]interface{}) (api.Charger, error) {
 		return nil, err
 	}
 
-	return NewsmartEVSE(cc.URI, cc.Device, cc.Comset, cc.Baudrate, modbus.ProtocolFromRTU(cc.RTU), cc.ID)
+	return NewsmartEVSE(cc.URI, cc.Device, cc.Comset, cc.Baudrate, cc.Protocol(), cc.ID)
 }
 
 // NewsmartEVSE creates a new charger
diff --git a/charger/solax.go b/charger/solax.go
index 818022e5..e7708a66 100644
--- a/charger/solax.go
+++ b/charger/solax.go
@@ -70,7 +70,7 @@ func NewSolaxFromConfig(other map[string]interface{}) (api.Charger, error) {
 		return nil, err
 	}
 
-	return NewSolax(cc.URI, cc.Device, cc.Comset, cc.Baudrate, modbus.ProtocolFromRTU(cc.RTU), cc.ID)
+	return NewSolax(cc.URI, cc.Device, cc.Comset, cc.Baudrate, cc.Protocol(), cc.ID)
 }
 
 // NewSolax creates Solax charger
diff --git a/charger/sungrow.go b/charger/sungrow.go
index f696f0fe..03a29f28 100644
--- a/charger/sungrow.go
+++ b/charger/sungrow.go
@@ -75,7 +75,7 @@ func NewSungrowFromConfig(other map[string]interface{}) (api.Charger, error) {
 		return nil, err
 	}
 
-	return NewSungrow(cc.URI, cc.Device, cc.Comset, cc.Baudrate, modbus.ProtocolFromRTU(cc.RTU), cc.ID)
+	return NewSungrow(cc.URI, cc.Device, cc.Comset, cc.Baudrate, cc.Protocol(), cc.ID)
 }
 
 // NewSungrow creates Sungrow charger
diff --git a/cmd/demo.yaml b/cmd/demo.yaml
index dcfe211e..dc1a327f 100644
--- a/cmd/demo.yaml
+++ b/cmd/demo.yaml
@@ -72,6 +72,11 @@ meters:
         if (state.batterySoc > 90) state.batterySoc = 10;
         state.batterySoc;
     capacity: 13.4
+    batterymode:
+      source: js
+      vm: shared
+      script: |
+        1
 
   - name: meter_charger_1
     type: custom
@@ -251,7 +256,14 @@ tariffs:
   currency: EUR # three letter ISO-4217 currency code (default EUR)
   grid:
     type: fixed
-    price: 0.399 # EUR/kWh
+    price: 0.2 # EUR/kWh
+    zones:
+      - hours: 5-7
+        price: 0.6
+      - hours: 11-14
+        price: 0.05
+      - hours: 17-21
+        price: 0.5
   feedin:
     type: fixed
     price: 0.08 # EUR/kWh
diff --git a/cmd/device.go b/cmd/device.go
index 06966403..6a738023 100644
--- a/cmd/device.go
+++ b/cmd/device.go
@@ -1,9 +1,7 @@
 package cmd
 
 import (
-	"cmp"
 	"fmt"
-	"slices"
 	"strings"
 
 	"github.com/evcc-io/evcc/util/config"
@@ -48,16 +46,7 @@ func runDevice(cmd *cobra.Command, args []string) {
 
 		for _, d := range devs {
 			fmt.Printf("%d. %s\n", d.ID, d.Type)
-
-			details := d.Details
-			slices.SortFunc(details, func(i, j config.ConfigDetail) int {
-				return cmp.Compare(i.Key, j.Key)
-			})
-
-			for _, d := range details {
-				fmt.Printf("%s: %s\n", d.Key, d.Value)
-			}
-
+			fmt.Println(d.Value)
 			fmt.Println()
 		}
 	}
diff --git a/cmd/setup.go b/cmd/setup.go
index 7dc7764f..96011aea 100644
--- a/cmd/setup.go
+++ b/cmd/setup.go
@@ -204,6 +204,8 @@ NEXT:
 }
 
 func configureMeters(static []config.Named, names ...string) error {
+	g, _ := errgroup.WithContext(context.Background())
+
 	for i, cc := range static {
 		if cc.Name == "" {
 			return fmt.Errorf("cannot create meter %d: missing name", i+1)
@@ -217,14 +219,18 @@ func configureMeters(static []config.Named, names ...string) error {
 			log.WARN.Printf("create meter %d: %v", i+1, err)
 		}
 
-		instance, err := meter.NewFromConfig(cc.Type, cc.Other)
-		if err != nil {
-			return &DeviceError{cc.Name, fmt.Errorf("cannot create meter '%s': %w", cc.Name, err)}
-		}
+		g.Go(func() error {
+			instance, err := meter.NewFromConfig(cc.Type, cc.Other)
+			if err != nil {
+				return &DeviceError{cc.Name, fmt.Errorf("cannot create meter '%s': %w", cc.Name, err)}
+			}
 
-		if err := config.Meters().Add(config.NewStaticDevice(cc, instance)); err != nil {
-			return &DeviceError{cc.Name, err}
-		}
+			if err := config.Meters().Add(config.NewStaticDevice(cc, instance)); err != nil {
+				return &DeviceError{cc.Name, err}
+			}
+
+			return nil
+		})
 	}
 
 	// append devices from database
@@ -234,25 +240,29 @@ func configureMeters(static []config.Named, names ...string) error {
 	}
 
 	for _, conf := range configurable {
-		cc := conf.Named()
+		g.Go(func() error {
+			cc := conf.Named()
 
-		if len(names) > 0 && !slices.Contains(names, cc.Name) {
-			return nil
-		}
+			if len(names) > 0 && !slices.Contains(names, cc.Name) {
+				return nil
+			}
 
-		// TOTO add fake devices
+			// TODO add fake devices
 
-		instance, err := meter.NewFromConfig(cc.Type, cc.Other)
-		if err != nil {
-			return &DeviceError{cc.Name, fmt.Errorf("cannot create meter '%s': %w", cc.Name, err)}
-		}
+			instance, err := meter.NewFromConfig(cc.Type, cc.Other)
+			if err != nil {
+				return &DeviceError{cc.Name, fmt.Errorf("cannot create meter '%s': %w", cc.Name, err)}
+			}
 
-		if err := config.Meters().Add(config.NewConfigurableDevice(conf, instance)); err != nil {
-			return &DeviceError{cc.Name, err}
-		}
+			if err := config.Meters().Add(config.NewConfigurableDevice(conf, instance)); err != nil {
+				return &DeviceError{cc.Name, err}
+			}
+
+			return nil
+		})
 	}
 
-	return nil
+	return g.Wait()
 }
 
 func configureChargers(static []config.Named, names ...string) error {
@@ -299,7 +309,7 @@ func configureChargers(static []config.Named, names ...string) error {
 				return nil
 			}
 
-			// TOTO add fake devices
+			// TODO add fake devices
 
 			instance, err := charger.NewFromConfig(cc.Type, cc.Other)
 			if err != nil {
diff --git a/core/helper.go b/core/helper.go
index 5110d384..846287e2 100644
--- a/core/helper.go
+++ b/core/helper.go
@@ -1,6 +1,7 @@
 package core
 
 import (
+	"fmt"
 	"time"
 
 	"github.com/cenkalti/backoff/v4"
@@ -51,3 +52,19 @@ func sitePower(log *util.Logger, maxGrid, grid, battery, residual float64) float
 
 	return grid + battery + residual
 }
+
+// printPtr returns a string representation of a pointer value
+func printPtr[T any](format string, v *T) string {
+	if v == nil {
+		return "<nil>"
+	}
+	return fmt.Sprintf(format, *v)
+}
+
+func ptrValueEqual[T comparable](a, b *T) bool {
+	if (a == nil) != (b == nil) {
+		return false
+	}
+
+	return a == nil && b == nil || (*a) == (*b)
+}
diff --git a/core/keys/site.go b/core/keys/site.go
index 6602b78e..07254c0d 100644
--- a/core/keys/site.go
+++ b/core/keys/site.go
@@ -39,6 +39,8 @@ const (
 	// battery settings
 	BatteryCapacity                   = "batteryCapacity"
 	BatteryDischargeControl           = "batteryDischargeControl"
+	BatteryGridChargeLimit            = "batteryGridChargeLimit"
+	BatteryGridChargeActive           = "batteryGridChargeActive"
 	BufferSoc                         = "bufferSoc"
 	BufferStartSoc                    = "bufferStartSoc"
 	MaxGridSupplyWhileBatteryCharging = "maxGridSupplyWhileBatteryCharging"
diff --git a/core/loadpoint.go b/core/loadpoint.go
index 5f32ced5..be6bb03e 100644
--- a/core/loadpoint.go
+++ b/core/loadpoint.go
@@ -121,12 +121,12 @@ type Loadpoint struct {
 	MinCurrent_       float64       `mapstructure:"minCurrent"`
 	MaxCurrent_       float64       `mapstructure:"maxCurrent"`
 
-	minCurrent       float64 // PV mode: start current	Min+PV mode: min current
-	maxCurrent       float64 // Max allowed current. Physically ensured by the charger
-	configuredPhases int     // Charger configured phase mode 0/1/3
-	limitSoc         int     // Session limit for soc
-	limitEnergy      float64 // Session limit for energy
-	smartCostLimit   float64 // always charge if cost is below this value
+	minCurrent       float64  // PV mode: start current	Min+PV mode: min current
+	maxCurrent       float64  // Max allowed current. Physically ensured by the charger
+	configuredPhases int      // Charger configured phase mode 0/1/3
+	limitSoc         int      // Session limit for soc
+	limitEnergy      float64  // Session limit for energy
+	smartCostLimit   *float64 // always charge if cost is below this value
 
 	mode                api.ChargeMode
 	enabled             bool      // Charger enabled state
@@ -337,8 +337,9 @@ func (lp *Loadpoint) restoreSettings() {
 		lp.setLimitEnergy(v)
 	}
 	if v, err := lp.settings.Float(keys.SmartCostLimit); err == nil {
-		lp.SetSmartCostLimit(v)
+		lp.SetSmartCostLimit(&v)
 	}
+
 	t, err1 := lp.settings.Time(keys.PlanTime)
 	v, err2 := lp.settings.Float(keys.PlanEnergy)
 	if err1 == nil && err2 == nil {
@@ -862,6 +863,11 @@ func (lp *Loadpoint) setLimit(chargeCurrent float64) error {
 		lp.setAndPublishEnabled(enabled)
 		lp.chargerSwitched = lp.clock.Now()
 
+		// ensure we always re-set current when enabling charger
+		if !enabled {
+			lp.chargeCurrent = 0
+		}
+
 		lp.bus.Publish(evChargeCurrent, chargeCurrent)
 
 		// start/stop vehicle wake-up timer
@@ -1632,9 +1638,18 @@ func (lp *Loadpoint) phaseSwitchCompleted() bool {
 }
 
 // Update is the main control function. It reevaluates meters and charger state
-func (lp *Loadpoint) Update(sitePower float64, smartCostActive bool, smartCostNextStart time.Time, batteryBuffered, batteryStart bool, greenShare float64, effPrice, effCo2 *float64) {
+func (lp *Loadpoint) Update(sitePower float64, rates api.Rates, batteryBuffered, batteryStart bool, greenShare float64, effPrice, effCo2 *float64) {
+	// smart cost
+	smartCostActive := lp.smartCostActive(rates)
 	lp.publish(keys.SmartCostActive, smartCostActive)
+
+	var smartCostNextStart time.Time
+	if !smartCostActive {
+		smartCostNextStart = lp.smartCostNextStart(rates)
+	}
 	lp.publish(keys.SmartCostNextStart, smartCostNextStart)
+
+	// long-running tasks
 	lp.processTasks()
 
 	// read and publish meters first- charge power and currents have already been updated by the site
diff --git a/core/loadpoint/api.go b/core/loadpoint/api.go
index 9eb97505..a6f4f308 100644
--- a/core/loadpoint/api.go
+++ b/core/loadpoint/api.go
@@ -110,9 +110,9 @@ type API interface {
 	//
 
 	// GetSmartChargingActive determines if smart charging is active
-	GetSmartCostLimit() float64
+	GetSmartCostLimit() *float64
 	// SetSmartCostLimit sets the smart cost limit
-	SetSmartCostLimit(limit float64)
+	SetSmartCostLimit(limit *float64)
 
 	//
 	// power and energy
diff --git a/core/loadpoint/mock.go b/core/loadpoint/mock.go
index cf7578bb..b1f4f3fe 100644
--- a/core/loadpoint/mock.go
+++ b/core/loadpoint/mock.go
@@ -380,10 +380,10 @@ func (mr *MockAPIMockRecorder) GetRemainingEnergy() *gomock.Call {
 }
 
 // GetSmartCostLimit mocks base method.
-func (m *MockAPI) GetSmartCostLimit() float64 {
+func (m *MockAPI) GetSmartCostLimit() *float64 {
 	m.ctrl.T.Helper()
 	ret := m.ctrl.Call(m, "GetSmartCostLimit")
-	ret0, _ := ret[0].(float64)
+	ret0, _ := ret[0].(*float64)
 	return ret0
 }
 
@@ -602,7 +602,7 @@ func (mr *MockAPIMockRecorder) SetPriority(arg0 any) *gomock.Call {
 }
 
 // SetSmartCostLimit mocks base method.
-func (m *MockAPI) SetSmartCostLimit(arg0 float64) {
+func (m *MockAPI) SetSmartCostLimit(arg0 *float64) {
 	m.ctrl.T.Helper()
 	m.ctrl.Call(m, "SetSmartCostLimit", arg0)
 }
diff --git a/core/loadpoint_api.go b/core/loadpoint_api.go
index 738dc302..b4b87eb0 100644
--- a/core/loadpoint_api.go
+++ b/core/loadpoint_api.go
@@ -489,23 +489,29 @@ func (lp *Loadpoint) StartVehicleDetection() {
 }
 
 // GetSmartCostLimit gets the smart cost limit
-func (lp *Loadpoint) GetSmartCostLimit() float64 {
+func (lp *Loadpoint) GetSmartCostLimit() *float64 {
 	lp.RLock()
 	defer lp.RUnlock()
 	return lp.smartCostLimit
 }
 
 // SetSmartCostLimit sets the smart cost limit
-func (lp *Loadpoint) SetSmartCostLimit(val float64) {
+func (lp *Loadpoint) SetSmartCostLimit(val *float64) {
 	lp.Lock()
 	defer lp.Unlock()
 
-	lp.log.DEBUG.Println("set smart cost limit:", val)
+	lp.log.DEBUG.Println("set smart cost limit:", printPtr("%.1f", val))
 
-	if lp.smartCostLimit != val {
+	if !ptrValueEqual(lp.smartCostLimit, val) {
 		lp.smartCostLimit = val
-		lp.settings.SetFloat(keys.SmartCostLimit, lp.smartCostLimit)
-		lp.publish(keys.SmartCostLimit, lp.smartCostLimit)
+
+		if val == nil {
+			lp.settings.SetString(keys.SmartCostLimit, "")
+			lp.publish(keys.SmartCostLimit, nil)
+		} else {
+			lp.settings.SetFloat(keys.SmartCostLimit, *val)
+			lp.publish(keys.SmartCostLimit, *val)
+		}
 	}
 }
 
diff --git a/core/loadpoint_smartcost.go b/core/loadpoint_smartcost.go
new file mode 100644
index 00000000..64594a75
--- /dev/null
+++ b/core/loadpoint_smartcost.go
@@ -0,0 +1,31 @@
+package core
+
+import (
+	"time"
+
+	"github.com/evcc-io/evcc/api"
+)
+
+func (lp *Loadpoint) smartCostActive(rates api.Rates) bool {
+	// potential error has already been logged by site, ignore
+	rate, _ := rates.Current(time.Now())
+	limit := lp.GetSmartCostLimit()
+	return limit != nil && !rate.IsEmpty() && rate.Price <= *limit
+}
+
+// smartCostNextStart returns the next start time for a smart cost rate below the limit
+func (lp *Loadpoint) smartCostNextStart(rates api.Rates) time.Time {
+	limit := lp.GetSmartCostLimit()
+	if limit == nil || rates == nil {
+		return time.Time{}
+	}
+
+	now := time.Now()
+	for _, slot := range rates {
+		if slot.Start.After(now) && slot.Price <= *limit {
+			return slot.Start
+		}
+	}
+
+	return time.Time{}
+}
diff --git a/core/planner/helper.go b/core/planner/helper.go
index 6c2977d1..66d558e4 100644
--- a/core/planner/helper.go
+++ b/core/planner/helper.go
@@ -52,7 +52,7 @@ func AverageCost(plan api.Rates) float64 {
 // SlotAt returns the slot for the given time or an empty slot
 func SlotAt(time time.Time, plan api.Rates) api.Rate {
 	for _, slot := range plan {
-		if (slot.Start.Before(time) || slot.Start.Equal(time)) && slot.End.After(time) {
+		if !slot.Start.After(time) && slot.End.After(time) {
 			return slot
 		}
 	}
diff --git a/core/planner/planner.go b/core/planner/planner.go
index 74929a4a..a9e3301f 100644
--- a/core/planner/planner.go
+++ b/core/planner/planner.go
@@ -89,12 +89,12 @@ func (t *Planner) continuousPlan(rates api.Rates, start, end time.Time) api.Rate
 	res := make(api.Rates, 0, len(rates)+2)
 	for _, r := range rates {
 		// slot before continuous plan
-		if r.End.Before(start) || r.End.Equal(start) {
+		if !r.End.After(start) {
 			continue
 		}
 
 		// slot after continuous plan
-		if r.Start.After(end) || r.Start.Equal(end) {
+		if !r.Start.Before(end) {
 			continue
 		}
 
diff --git a/core/site.go b/core/site.go
index f069063b..6a5a1350 100644
--- a/core/site.go
+++ b/core/site.go
@@ -37,7 +37,7 @@ const standbyPower = 10 // consider less than 10W as charger in standby
 // updater abstracts the Loadpoint implementation for testing
 type updater interface {
 	loadpoint.API
-	Update(availablePower float64, smartCostActive bool, smartCostNextStart time.Time, batteryBuffered, batteryStart bool, greenShare float64, effectivePrice, effectiveCo2 *float64)
+	Update(availablePower float64, rates api.Rates, batteryBuffered, batteryStart bool, greenShare float64, effectivePrice, effectiveCo2 *float64)
 }
 
 // meterMeasurement is used as slice element for publishing structured data
@@ -85,10 +85,11 @@ type Site struct {
 	auxMeters     []api.Meter // Auxiliary meters
 
 	// battery settings
-	prioritySoc             float64 // prefer battery up to this Soc
-	bufferSoc               float64 // continue charging on battery above this Soc
-	bufferStartSoc          float64 // start charging on battery above this Soc
-	batteryDischargeControl bool    // prevent battery discharge for fast and planned charging
+	prioritySoc             float64  // prefer battery up to this Soc
+	bufferSoc               float64  // continue charging on battery above this Soc
+	bufferStartSoc          float64  // start charging on battery above this Soc
+	batteryDischargeControl bool     // prevent battery discharge for fast and planned charging
+	batteryGridChargeLimit  *float64 // grid charging limit
 
 	loadpoints  []*Loadpoint             // Loadpoints
 	tariffs     *tariff.Tariffs          // Tariffs
@@ -298,6 +299,10 @@ func (site *Site) restoreSettings() error {
 			return err
 		}
 	}
+	if v, err := settings.Float(keys.BatteryGridChargeLimit); err == nil {
+		site.SetBatteryGridChargeLimit(&v)
+	}
+
 	return nil
 }
 
@@ -792,19 +797,25 @@ func (site *Site) update(lp updater) {
 		flexiblePower = site.prioritizer.GetChargePowerFlexibility(lp)
 	}
 
-	var smartCostActive bool
-	if rate, err := site.plannerRate(); err == nil {
-		smartCostActive = site.smartCostActive(lp, rate)
-	} else {
-		site.log.WARN.Println("smartCostActive:", err)
+	// battery mode handling
+	rates, err := site.plannerRates()
+	if err != nil {
+		site.log.WARN.Println("planner:", err)
+	}
+
+	rate, err := rates.Current(time.Now())
+	if rates != nil && err != nil {
+		site.log.WARN.Println("planner:", err)
 	}
 
-	var smartCostNextStart time.Time
-	if !smartCostActive {
-		if rates, err := site.plannerRates(); err == nil {
-			smartCostNextStart = site.smartCostNextStart(lp, rates)
+	batteryGridChargeActive := site.batteryGridChargeActive(rate)
+	site.publish(keys.BatteryGridChargeActive, batteryGridChargeActive)
+
+	if batteryMode := site.requiredBatteryMode(batteryGridChargeActive, rate); batteryMode != api.BatteryUnknown {
+		if err := site.applyBatteryMode(batteryMode); err == nil {
+			site.SetBatteryMode(batteryMode)
 		} else {
-			site.log.WARN.Println("smartCostNextStart:", err)
+			site.log.ERROR.Println("battery mode:", err)
 		}
 	}
 
@@ -820,7 +831,7 @@ func (site *Site) update(lp updater) {
 		greenShareHome := site.greenShare(0, homePower)
 		greenShareLoadpoints := site.greenShare(nonChargePower, nonChargePower+totalChargePower)
 
-		lp.Update(sitePower, smartCostActive, smartCostNextStart, batteryBuffered, batteryStart, greenShareLoadpoints, site.effectivePrice(greenShareLoadpoints), site.effectiveCo2(greenShareLoadpoints))
+		lp.Update(sitePower, rates, batteryBuffered, batteryStart, greenShareLoadpoints, site.effectivePrice(greenShareLoadpoints), site.effectiveCo2(greenShareLoadpoints))
 
 		site.Health.Update()
 
@@ -833,10 +844,6 @@ func (site *Site) update(lp updater) {
 		site.log.ERROR.Println(err)
 	}
 
-	if site.GetBatteryDischargeControl() {
-		site.updateBatteryMode()
-	}
-
 	site.stats.Update(site)
 }
 
diff --git a/core/site/api.go b/core/site/api.go
index 2dc9430f..6fa731f7 100644
--- a/core/site/api.go
+++ b/core/site/api.go
@@ -40,6 +40,11 @@ type API interface {
 	GetMaxGridSupplyWhileBatteryCharging() float64
 	SetMaxGridSupplyWhileBatteryCharging(float64) error
 
+	// GetBatteryGridChargeLimit get the grid charge limit
+	GetBatteryGridChargeLimit() *float64
+	// SetBatteryGridChargeLimit sets the grid charge limit
+	SetBatteryGridChargeLimit(limit *float64)
+
 	//
 	// power and energy
 	//
diff --git a/core/site_api.go b/core/site_api.go
index 55f28d03..4a98bafc 100644
--- a/core/site_api.go
+++ b/core/site_api.go
@@ -347,13 +347,6 @@ func (site *Site) SetBatteryDischargeControl(val bool) error {
 	defer site.Unlock()
 
 	if site.batteryDischargeControl != val {
-		// reset to normal when disabling
-		if mode := site.batteryMode; !val && batteryModeModified(mode) {
-			if err := site.applyBatteryMode(api.BatteryNormal); err != nil {
-				return err
-			}
-		}
-
 		site.batteryDischargeControl = val
 		settings.SetBool(keys.BatteryDischargeControl, val)
 		site.publish(keys.BatteryDischargeControl, val)
@@ -361,3 +354,28 @@ func (site *Site) SetBatteryDischargeControl(val bool) error {
 
 	return nil
 }
+
+func (site *Site) GetBatteryGridChargeLimit() *float64 {
+	site.RLock()
+	defer site.RUnlock()
+	return site.batteryGridChargeLimit
+}
+
+func (site *Site) SetBatteryGridChargeLimit(val *float64) {
+	site.log.DEBUG.Println("set grid charge limit:", printPtr("%.1f", val))
+
+	site.Lock()
+	defer site.Unlock()
+
+	if !ptrValueEqual(site.batteryGridChargeLimit, val) {
+		site.batteryGridChargeLimit = val
+
+		if val == nil {
+			settings.SetString(keys.BatteryGridChargeLimit, "")
+			site.publish(keys.BatteryGridChargeLimit, nil)
+		} else {
+			settings.SetFloat(keys.BatteryGridChargeLimit, *val)
+			site.publish(keys.BatteryGridChargeLimit, *val)
+		}
+	}
+}
diff --git a/core/site_battery.go b/core/site_battery.go
index 3804aebc..a8da20ae 100644
--- a/core/site_battery.go
+++ b/core/site_battery.go
@@ -1,8 +1,6 @@
 package core
 
 import (
-	"time"
-
 	"github.com/evcc-io/evcc/api"
 	"github.com/evcc-io/evcc/core/keys"
 	"github.com/evcc-io/evcc/core/loadpoint"
@@ -37,10 +35,29 @@ func (site *Site) SetBatteryMode(batMode api.BatteryMode) {
 	}
 }
 
-// applyBatteryMode applies the mode to each battery and updates
-// internal state if successful (requires lock)
+// requiredBatteryMode determines required battery mode based on grid charge and rate
+func (site *Site) requiredBatteryMode(batteryGridChargeActive bool, rate api.Rate) api.BatteryMode {
+	var res api.BatteryMode
+	batMode := site.GetBatteryMode()
+
+	mapper := func(s api.BatteryMode) api.BatteryMode {
+		return map[bool]api.BatteryMode{false: s, true: api.BatteryUnknown}[batMode == s]
+	}
+
+	switch {
+	case batteryGridChargeActive:
+		res = mapper(api.BatteryCharge)
+	case site.dischargeControlActive(rate):
+		res = mapper(api.BatteryHold)
+	case batteryModeModified(batMode):
+		res = api.BatteryNormal
+	}
+
+	return res
+}
+
+// applyBatteryMode applies the mode to each battery
 func (site *Site) applyBatteryMode(mode api.BatteryMode) error {
-	// update batteries
 	for _, meter := range site.batteryMeters {
 		if batCtrl, ok := meter.(api.BatteryController); ok {
 			if err := batCtrl.SetBatteryMode(mode); err != nil {
@@ -49,9 +66,6 @@ func (site *Site) applyBatteryMode(mode api.BatteryMode) error {
 		}
 	}
 
-	// update state and publish
-	site.setBatteryMode(mode)
-
 	return nil
 }
 
@@ -64,62 +78,27 @@ func (site *Site) plannerRates() (api.Rates, error) {
 	return tariff.Rates()
 }
 
-func (site *Site) plannerRate() (*api.Rate, error) {
-	rates, err := site.plannerRates()
-	if rates == nil || err != nil {
-		return nil, err
-	}
-
-	rate, err := rates.Current(time.Now())
-	if err != nil {
-		return nil, err
-	}
-
-	return &rate, nil
-}
-
-func (site *Site) smartCostActive(lp loadpoint.API, rate *api.Rate) bool {
+func (site *Site) smartCostActive(lp loadpoint.API, rate api.Rate) bool {
 	limit := lp.GetSmartCostLimit()
-	return limit != 0 && rate != nil && rate.Price <= limit
+	return limit != nil && !rate.IsEmpty() && rate.Price <= *limit
 }
 
-func (site *Site) smartCostNextStart(lp loadpoint.API, rate api.Rates) time.Time {
-	limit := lp.GetSmartCostLimit()
-	if limit == 0 || rate == nil {
-		return time.Time{}
-	}
-
-	now := time.Now()
-	for _, slot := range rate {
-		if slot.Start.After(now) && slot.Price <= limit {
-			return slot.Start
-		}
-	}
-
-	return time.Time{}
+func (site *Site) batteryGridChargeActive(rate api.Rate) bool {
+	limit := site.GetBatteryGridChargeLimit()
+	return limit != nil && !rate.IsEmpty() && rate.Price <= *limit
 }
 
-func (site *Site) updateBatteryMode() {
-	mode := api.BatteryNormal
-
-	rate, err := site.plannerRate()
-	if err != nil {
-		site.log.WARN.Println("smart cost:", err)
+func (site *Site) dischargeControlActive(rate api.Rate) bool {
+	if !site.GetBatteryDischargeControl() {
+		return false
 	}
 
 	for _, lp := range site.Loadpoints() {
 		smartCostActive := site.smartCostActive(lp, rate)
 		if lp.GetStatus() == api.StatusC && (smartCostActive || lp.IsFastChargingActive()) {
-			mode = api.BatteryHold
-			break
+			return true
 		}
 	}
 
-	if batMode := site.GetBatteryMode(); mode != batMode {
-		site.Lock()
-		if err := site.applyBatteryMode(mode); err != nil {
-			site.log.ERROR.Println("battery mode:", err)
-		}
-		site.Unlock()
-	}
+	return false
 }
diff --git a/go.mod b/go.mod
index 8b84e7c2..17b14b1c 100644
--- a/go.mod
+++ b/go.mod
@@ -89,7 +89,7 @@ require (
 	github.com/teslamotors/vehicle-command v0.0.2
 	github.com/traefik/yaegi v0.16.1
 	github.com/tv42/httpunix v0.0.0-20191220191345-2ba4b9c3382c
-	github.com/volkszaehler/mbmd v0.0.0-20240611142726-33463eb0324e
+	github.com/volkszaehler/mbmd v0.0.0-20240727104742-3191c0dbfb9e
 	github.com/writeas/go-strip-markdown/v2 v2.1.1
 	gitlab.com/bboehmke/sunny v0.16.0
 	go.uber.org/mock v0.4.0
@@ -197,6 +197,8 @@ require (
 
 replace gopkg.in/yaml.v3 => github.com/andig/yaml v0.0.0-20240531135838-1ff5761ab467
 
+replace github.com/grid-x/modbus => github.com/evcc-io/modbus v0.0.0-20240503125516-9fd99fe0e438
+
 replace github.com/enbility/spine-go => github.com/enbility/spine-go v0.0.0-20240726200332-a983de1e34b8
 
 replace github.com/enbility/ship-go => github.com/enbility/ship-go v0.0.0-20240731093131-37b1302bca66
diff --git a/go.sum b/go.sum
index 5fa521d3..39dc84fe 100644
--- a/go.sum
+++ b/go.sum
@@ -132,6 +132,8 @@ github.com/enbility/zeroconf/v2 v2.0.0-20240210101930-d0004078577b/go.mod h1:Bjz
 github.com/envoyproxy/go-control-plane v0.6.9/go.mod h1:SBwIajubJHhxtWwsL9s8ss4safvEdbitLhGGK48rN6g=
 github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
 github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c=
+github.com/evcc-io/modbus v0.0.0-20240503125516-9fd99fe0e438 h1:I14MN2UauarS2Lo+prnNXEj31MxJS1mEfLUrEnOUBsw=
+github.com/evcc-io/modbus v0.0.0-20240503125516-9fd99fe0e438/go.mod h1:WpbUAyptAAi0VAriSRopZa6uhiJOJCTz7KFvgGtNRXc=
 github.com/evcc-io/ocpp-go v0.0.0-20240730071053-d69e53b0fce9 h1:FLv1vmLnfc8DanI5U1qOTe1Zr0OgZ/tuOvHALtZ2sOI=
 github.com/evcc-io/ocpp-go v0.0.0-20240730071053-d69e53b0fce9/go.mod h1:ZynYDWGw6CslG3vyPuucLsy6AyE+h3XXYlr39jhNiQY=
 github.com/evcc-io/tesla-proxy-client v0.0.0-20240221194046-4168b3759701 h1:3JplY3KS6KMDVDNAU+3+KWmSWmoHIU34qwuIpW6SiHk=
@@ -276,10 +278,6 @@ github.com/gregdel/pushover v1.3.1 h1:4bMLITOZ15+Zpi6qqoGqOPuVHCwSUvMCgVnN5Xhilf
 github.com/gregdel/pushover v1.3.1/go.mod h1:EcaO66Nn1StkpEm1iKtBTV3d2A16SoMsVER1PthX7to=
 github.com/gregjones/httpcache v0.0.0-20190611155906-901d90724c79 h1:+ngKgrYPPJrOjhax5N+uePQ0Fh1Z7PheYoUI/0nzkPA=
 github.com/gregjones/httpcache v0.0.0-20190611155906-901d90724c79/go.mod h1:FecbI9+v66THATjSRHfNgh1IVFe/9kFxbXtjV0ctIMA=
-github.com/grid-x/modbus v0.0.0-20210714071042-7af2b65ec03b/go.mod h1:YaK0rKJenZ74vZFcSSLlAQqtG74PMI68eDjpDCDDmTw=
-github.com/grid-x/modbus v0.0.0-20240503115206-582f2ab60a18 h1:8V5xRtdD70kGC4/IHqFq+kcBSWr4k6nscAUgWwJ6A5k=
-github.com/grid-x/modbus v0.0.0-20240503115206-582f2ab60a18/go.mod h1:WpbUAyptAAi0VAriSRopZa6uhiJOJCTz7KFvgGtNRXc=
-github.com/grid-x/serial v0.0.0-20191104121038-e24bc9bf6f08/go.mod h1:kdOd86/VGFWRrtkNwf1MPk0u1gIjc4Y7R2j7nhwc7Rk=
 github.com/grid-x/serial v0.0.0-20211107191517-583c7356b3aa h1:Rsn6ARgNkXrsXJIzhkE4vQr5Gbx2LvtEMv4BJOK4LyU=
 github.com/grid-x/serial v0.0.0-20211107191517-583c7356b3aa/go.mod h1:kdOd86/VGFWRrtkNwf1MPk0u1gIjc4Y7R2j7nhwc7Rk=
 github.com/grpc-ecosystem/go-grpc-middleware v1.0.1-0.20190118093823-f849b5445de4/go.mod h1:FiyG127CGDf3tlThmgyCl78X/SZQqEOJBCDaAfeWzPs=
@@ -662,8 +660,8 @@ github.com/vmihailenco/msgpack/v5 v5.4.1 h1:cQriyiUvjTwOHg8QZaPihLWeRAAVoCpE00IU
 github.com/vmihailenco/msgpack/v5 v5.4.1/go.mod h1:GaZTsDaehaPpQVyxrf5mtQlH+pc21PIudVV/E3rRQok=
 github.com/vmihailenco/tagparser/v2 v2.0.0 h1:y09buUbR+b5aycVFQs/g70pqKVZNBmxwAhO7/IwNM9g=
 github.com/vmihailenco/tagparser/v2 v2.0.0/go.mod h1:Wri+At7QHww0WTrCBeu4J6bNtoV6mEfg5OIWRZA9qds=
-github.com/volkszaehler/mbmd v0.0.0-20240611142726-33463eb0324e h1:1iAo0qalenDcyo9ySgRWjDuMTpfyA/rBDKklMMNahZk=
-github.com/volkszaehler/mbmd v0.0.0-20240611142726-33463eb0324e/go.mod h1:p1nUKfszvbZ+mMMsMi1vaKGA/eBAXC34VSKCk5slyG4=
+github.com/volkszaehler/mbmd v0.0.0-20240727104742-3191c0dbfb9e h1:d0KoYuIPXTI/vn85axsoD7w616oUalAzmDLZwv9yB9Q=
+github.com/volkszaehler/mbmd v0.0.0-20240727104742-3191c0dbfb9e/go.mod h1:MA3vKWI4KozcpWvD9dwN6CL+WqwdK9XGXT5Iv49IsUM=
 github.com/writeas/go-strip-markdown/v2 v2.1.1 h1:hAxUM21Uhznf/FnbVGiJciqzska6iLei22Ijc3q2e28=
 github.com/writeas/go-strip-markdown/v2 v2.1.1/go.mod h1:UvvgPJgn1vvN8nWuE5e7v/+qmDu3BSVnKAB6Gl7hFzA=
 github.com/xiang90/probing v0.0.0-20190116061207-43a291ad63a2/go.mod h1:UETIi67q53MR2AWcXfiuqkDkRtnGDLqkBTpCHuJHxtU=
diff --git a/hems/relay/steuerbox.go b/hems/relay/relay.go
similarity index 100%
rename from hems/relay/steuerbox.go
rename to hems/relay/relay.go
diff --git a/i18n/de.toml b/i18n/de.toml
index 257f6a79..3af110a2 100644
--- a/i18n/de.toml
+++ b/i18n/de.toml
@@ -5,20 +5,21 @@ control = "Batteriesteuerung"
 discharge = "Verhindere Entladung im Schnell-Modus und bei geplantem Laden."
 disclaimerHint = "Hinweis:"
 disclaimerText = "Nur relevant im PV-Modus. Das Ladeverhalten wird entsprechend angepasst."
-legendBottomName = "Haus hat Priorität"
-legendBottomSubline = "nicht zum Laden verwenden"
-legendMiddleName = "Fahrzeug zuerst"
-legendMiddleSubline = "dann das Haus"
-legendTitle = "Wie soll Sonnenstrom genutzt werden?"
-legendTopAutostart = "startet automatisch"
-legendTopName = "Batterieunterstütztes Laden"
-legendTopSubline = "ohne Unterbrechungen"
+gridChargeTab = "Netzladen"
+legendBottomName = "Priorisiere die Hausbatterie"
+legendBottomSubline = "bis sie {soc} erreicht hat."
+legendMiddleName = "Priorisiere Fahrzeugladen,"
+legendMiddleSubline = "wenn Hausbatterie über {soc} ist."
+legendTopAutostart = "Starte automatisch,"
+legendTopName = "Batterieunterstütztes Fahrzeugladen,"
+legendTopSubline = "wenn Hausbatterie über {soc} ist."
 modalTitle = "Hausbatterie"
+usageTab = "Batterienutzung"
 
 [batterySettings.bufferStart]
-above = "wenn über {soc}"
-full = "wenn auf {soc}"
-never = "nur mit genug PV-Überschuss"
+above = "wenn über {soc}."
+full = "wenn auf {soc}."
+never = "nur mit genug PV-Überschuss."
 
 [config]
 
@@ -389,6 +390,8 @@ batteryCharge = "Batterie laden"
 batteryDischarge = "Batterie entladen"
 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/en.toml b/i18n/en.toml
index 9ebaaed5..655bbf33 100644
--- a/i18n/en.toml
+++ b/i18n/en.toml
@@ -5,20 +5,21 @@ control = "Battery control"
 discharge = "Prevent discharge in fast mode and planned charging."
 disclaimerHint = "Note:"
 disclaimerText = "These settings only affect solar mode. Charging behaviour is adjusted accordingly."
-legendBottomName = "home priority"
-legendBottomSubline = "not used for charging"
-legendMiddleName = "vehicle first"
-legendMiddleSubline = "home second"
-legendTitle = "How should solar energy be used?"
-legendTopAutostart = "starts automatically"
-legendTopName = "battery supported charging"
-legendTopSubline = "without interruptions"
+gridChargeTab = "Grid charging"
+legendBottomName = "Prioritize home battery charging"
+legendBottomSubline = "until it reaches {soc}."
+legendMiddleName = "Prioritize vehicle charging"
+legendMiddleSubline = "when home battery is above {soc}."
+legendTopAutostart = "Start automatically"
+legendTopName = "Battery-supported vehicle charging"
+legendTopSubline = "when home battery is above {soc}."
 modalTitle = "Home Battery"
+usageTab = "Battery usage"
 
 [batterySettings.bufferStart]
-above = "when above {soc}"
-full = "when at {soc}"
-never = "only with enough surplus"
+above = "when above {soc}."
+full = "when at {soc}."
+never = "only with enough surplus."
 
 [config]
 
@@ -391,6 +392,8 @@ batteryCharge = "Battery charging"
 batteryDischarge = "Battery discharging"
 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/meter/config.go b/meter/config.go
index 68555c6b..3a122899 100644
--- a/meter/config.go
+++ b/meter/config.go
@@ -1,31 +1,21 @@
 package meter
 
 import (
+	"context"
 	"fmt"
 	"strings"
 
 	"github.com/evcc-io/evcc/api"
+	reg "github.com/evcc-io/evcc/util/registry"
 )
 
-type meterRegistry map[string]func(map[string]interface{}) (api.Meter, error)
+var registry = reg.New[api.Meter]("meter")
 
-func (r meterRegistry) Add(name string, factory func(map[string]interface{}) (api.Meter, error)) {
-	if _, exists := r[name]; exists {
-		panic(fmt.Sprintf("cannot register duplicate meter type: %s", name))
-	}
-	r[name] = factory
+// Types returns the list of types
+func Types() []string {
+	return registry.Types()
 }
 
-func (r meterRegistry) Get(name string) (func(map[string]interface{}) (api.Meter, error), error) {
-	factory, exists := r[name]
-	if !exists {
-		return nil, fmt.Errorf("invalid meter type: %s", name)
-	}
-	return factory, nil
-}
-
-var registry meterRegistry = make(map[string]func(map[string]interface{}) (api.Meter, error))
-
 // NewFromConfig creates meter from configuration
 func NewFromConfig(typ string, other map[string]interface{}) (api.Meter, error) {
 	factory, err := registry.Get(strings.ToLower(typ))
@@ -33,7 +23,7 @@ func NewFromConfig(typ string, other map[string]interface{}) (api.Meter, error)
 		return nil, err
 	}
 
-	v, err := factory(other)
+	v, err := factory(context.TODO(), other)
 	if err != nil {
 		err = fmt.Errorf("cannot create meter type '%s': %w", typ, err)
 	}
diff --git a/meter/mbmd.go b/meter/mbmd.go
index 55164484..29876ea5 100644
--- a/meter/mbmd.go
+++ b/meter/mbmd.go
@@ -57,20 +57,19 @@ func NewModbusMbmdFromConfig(other map[string]interface{}) (api.Meter, error) {
 		cc.RTU = &b
 	}
 
-	conn, err := modbus.NewConnection(cc.URI, cc.Device, cc.Comset, cc.Baudrate, modbus.ProtocolFromRTU(cc.RTU), cc.ID)
+	modbus.Lock()
+	defer modbus.Unlock()
+
+	conn, err := modbus.NewConnection(cc.URI, cc.Device, cc.Comset, cc.Baudrate, cc.Settings.Protocol(), cc.ID)
 	if err != nil {
 		return nil, err
 	}
 
-	// set non-default delay
-	if cc.Delay > 0 {
-		conn.Delay(cc.Delay)
-	}
-
 	// set non-default timeout
-	if cc.Timeout > 0 {
-		conn.Timeout(cc.Timeout)
-	}
+	conn.Timeout(cc.Timeout)
+
+	// set non-default delay
+	conn.Delay(cc.Delay)
 
 	log := util.NewLogger("modbus")
 	conn.Logger(log.TRACE)
diff --git a/provider/modbus.go b/provider/modbus.go
index b92f55d0..51ac94b3 100644
--- a/provider/modbus.go
+++ b/provider/modbus.go
@@ -41,25 +41,22 @@ func NewModbusFromConfig(other map[string]interface{}) (Provider, error) {
 		return nil, err
 	}
 
-	conn, err := modbus.NewConnection(cc.URI, cc.Device, cc.Comset, cc.Baudrate, modbus.ProtocolFromRTU(cc.RTU), cc.ID)
+	modbus.Lock()
+	defer modbus.Unlock()
+
+	conn, err := modbus.NewConnection(cc.URI, cc.Device, cc.Comset, cc.Baudrate, cc.Settings.Protocol(), cc.ID)
 	if err != nil {
 		return nil, err
 	}
 
 	// set non-default timeout
-	if cc.Timeout > 0 {
-		conn.Timeout(cc.Timeout)
-	}
+	conn.Timeout(cc.Timeout)
 
 	// set non-default delay
-	if cc.Delay > 0 {
-		conn.Delay(cc.Delay)
-	}
+	conn.Delay(cc.Delay)
 
 	// set non-default connect delay
-	if cc.ConnectDelay > 0 {
-		conn.ConnectDelay(cc.ConnectDelay)
-	}
+	conn.ConnectDelay(cc.ConnectDelay)
 
 	log := util.NewLogger("modbus")
 	conn.Logger(log.TRACE)
diff --git a/provider/modbus_sunspec.go b/provider/modbus_sunspec.go
index 39d7aec1..6b5df688 100644
--- a/provider/modbus_sunspec.go
+++ b/provider/modbus_sunspec.go
@@ -44,25 +44,22 @@ func NewModbusSunspecFromConfig(other map[string]interface{}) (Provider, error)
 		return nil, err
 	}
 
+	modbus.Lock()
+	defer modbus.Unlock()
+
 	conn, err := modbus.NewConnection(cc.URI, cc.Device, cc.Comset, cc.Baudrate, modbus.Tcp, cc.ID)
 	if err != nil {
 		return nil, err
 	}
 
 	// set non-default timeout
-	if cc.Timeout > 0 {
-		conn.Timeout(cc.Timeout)
-	}
+	conn.Timeout(cc.Timeout)
 
 	// set non-default delay
-	if cc.Delay > 0 {
-		conn.Delay(cc.Delay)
-	}
+	conn.Delay(cc.Delay)
 
 	// set non-default connect delay
-	if cc.ConnectDelay > 0 {
-		conn.ConnectDelay(cc.ConnectDelay)
-	}
+	conn.ConnectDelay(cc.ConnectDelay)
 
 	log := util.NewLogger("sunspec")
 	conn.Logger(log.TRACE)
diff --git a/push/telegram.go b/push/telegram.go
index 23490925..cbda1ef9 100644
--- a/push/telegram.go
+++ b/push/telegram.go
@@ -65,6 +65,9 @@ func (m *Telegram) trackChats() {
 	conf.Timeout = 1000
 
 	for update := range m.bot.GetUpdatesChan(conf) {
+		if update.Message == nil || update.Message.Chat == nil {
+			continue
+		}
 		m.Lock()
 		if _, ok := m.chats[update.Message.Chat.ID]; !ok {
 			m.log.INFO.Printf("new chat id: %d", update.Message.Chat.ID)
diff --git a/server/http.go b/server/http.go
index 2573bac7..e394241e 100644
--- a/server/http.go
+++ b/server/http.go
@@ -105,10 +105,13 @@ func (s *HTTPd) RegisterSiteHandlers(site site.API, valueChan chan<- util.Param)
 		"buffersoc":               {"POST", "/buffersoc/{value:[0-9.]+}", floatHandler(site.SetBufferSoc, site.GetBufferSoc)},
 		"bufferstartsoc":          {"POST", "/bufferstartsoc/{value:[0-9.]+}", floatHandler(site.SetBufferStartSoc, site.GetBufferStartSoc)},
 		"batterydischargecontrol": {"POST", "/batterydischargecontrol/{value:[a-z]+}", boolHandler(site.SetBatteryDischargeControl, site.GetBatteryDischargeControl)},
+		"batterygridcharge":       {"POST", "/batterygridchargelimit/{value:-?[0-9.]+}", floatPtrHandler(pass(site.SetBatteryGridChargeLimit), site.GetBatteryGridChargeLimit)},
+		"batterygridchargedelete": {"DELETE", "/batterygridchargelimit", floatPtrHandler(pass(site.SetBatteryGridChargeLimit), site.GetBatteryGridChargeLimit)},
 		"maxgridsupply":           {"POST", "/maxgridsupply/{value:[0-9.]+}", floatHandler(site.SetMaxGridSupplyWhileBatteryCharging, site.GetMaxGridSupplyWhileBatteryCharging)},
 		"prioritysoc":             {"POST", "/prioritysoc/{value:[0-9.]+}", floatHandler(site.SetPrioritySoc, site.GetPrioritySoc)},
 		"residualpower":           {"POST", "/residualpower/{value:-?[0-9.]+}", floatHandler(site.SetResidualPower, site.GetResidualPower)},
 		"smartcost":               {"POST", "/smartcostlimit/{value:-?[0-9.]+}", updateSmartCostLimit(site)},
+		"smartcostdelete":         {"DELETE", "/smartcostlimit", updateSmartCostLimit(site)},
 		"tariff":                  {"GET", "/tariff/{tariff:[a-z]+}", tariffHandler(site)},
 		"sessions":                {"GET", "/sessions", sessionHandler},
 		"updatesession":           {"PUT", "/session/{id:[0-9]+}", updateSessionHandler},
@@ -173,7 +176,8 @@ func (s *HTTPd) RegisterSiteHandlers(site site.API, valueChan chan<- util.Param)
 			"remotedemand":     {"POST", "/remotedemand/{demand:[a-z]+}/{source:[0-9a-zA-Z_-]+}", remoteDemandHandler(lp)},
 			"enableThreshold":  {"POST", "/enable/threshold/{value:-?[0-9.]+}", floatHandler(pass(lp.SetEnableThreshold), lp.GetEnableThreshold)},
 			"disableThreshold": {"POST", "/disable/threshold/{value:-?[0-9.]+}", floatHandler(pass(lp.SetDisableThreshold), lp.GetDisableThreshold)},
-			"smartCostLimit":   {"POST", "/smartcostlimit/{value:-?[0-9.]+}", floatHandler(pass(lp.SetSmartCostLimit), lp.GetSmartCostLimit)},
+			"smartCost":        {"POST", "/smartcostlimit/{value:-?[0-9.]+}", floatPtrHandler(pass(lp.SetSmartCostLimit), lp.GetSmartCostLimit)},
+			"smartCostDelete":  {"DELETE", "/smartcostlimit", floatPtrHandler(pass(lp.SetSmartCostLimit), lp.GetSmartCostLimit)},
 			// "priority":         {"POST", "/priority/{value:[0-9.]+}", floatHandler(pass(lp.SetPriority), lp.GetPriority)},
 		}
 
diff --git a/server/http_config_device_handler.go b/server/http_config_device_handler.go
index fabc9711..ff9403f9 100644
--- a/server/http_config_device_handler.go
+++ b/server/http_config_device_handler.go
@@ -71,7 +71,9 @@ func deviceConfigMap[T any](class templates.Class, dev config.Device[T]) (map[st
 
 	dc := map[string]any{
 		"name": conf.Name,
-		"type": conf.Type,
+	}
+	if conf.Type != "" {
+		dc["type"] = conf.Type
 	}
 
 	if configurable, ok := dev.(config.ConfigurableDevice[T]); ok {
diff --git a/server/http_site_handler.go b/server/http_site_handler.go
index 7f6e2f3b..622af418 100644
--- a/server/http_site_handler.go
+++ b/server/http_site_handler.go
@@ -131,6 +131,20 @@ func floatHandler(set func(float64) error, get func() float64) http.HandlerFunc
 	return handler(parseFloat, set, get)
 }
 
+// floatPtrHandler updates float-pointer api
+func floatPtrHandler(set func(*float64) error, get func() *float64) http.HandlerFunc {
+	return handler(func(s string) (*float64, error) {
+		var val *float64
+		f, err := parseFloat(s)
+		if err == nil {
+			val = &f
+		} else if s == "" {
+			err = nil
+		}
+		return val, err
+	}, set, get)
+}
+
 // intHandler updates int-param api
 func intHandler(set func(int) error, get func() int) http.HandlerFunc {
 	return handler(strconv.Atoi, set, get)
@@ -152,11 +166,16 @@ func boolGetHandler(get func() bool) http.HandlerFunc {
 func updateSmartCostLimit(site site.API) http.HandlerFunc {
 	return func(w http.ResponseWriter, r *http.Request) {
 		vars := mux.Vars(r)
+		var val *float64
 
-		val, err := parseFloat(vars["value"])
-		if err != nil {
-			jsonError(w, http.StatusBadRequest, err)
-			return
+		if r.Method != http.MethodDelete {
+			f, err := parseFloat(vars["value"])
+			if err != nil {
+				jsonError(w, http.StatusBadRequest, err)
+				return
+			}
+
+			val = &f
 		}
 
 		for _, lp := range site.Loadpoints() {
diff --git a/server/modbus/handler.go b/server/modbus/handler.go
index caae01aa..d5a3b9e0 100644
--- a/server/modbus/handler.go
+++ b/server/modbus/handler.go
@@ -98,7 +98,7 @@ LOOP:
 
 func (h *handler) HandleDiscreteInputs(req *mbserver.DiscreteInputsRequest) ([]bool, error) {
 	h.log.TRACE.Printf("read discrete: id %d addr %d qty %d", req.UnitId, req.Addr, req.Quantity)
-	b, err := h.conn.ReadDiscreteInputsWithSlave(req.UnitId, req.Addr, req.Quantity)
+	b, err := h.conn.Clone(req.UnitId).ReadDiscreteInputs(req.Addr, req.Quantity)
 	return h.bytesToBoolResult("read discrete", req.Quantity, b, err)
 }
 
@@ -120,24 +120,24 @@ func (h *handler) HandleCoils(req *mbserver.CoilsRequest) ([]bool, error) {
 				u = 0xFF00
 			}
 
-			b, err := h.conn.WriteSingleCoilWithSlave(req.UnitId, req.Addr, u)
+			b, err := h.conn.Clone(req.UnitId).WriteSingleCoil(req.Addr, u)
 			return h.bytesToBoolResult("write coil", req.Quantity, b, err)
 		}
 
 		h.log.TRACE.Printf("write coils: id %d addr %d qty %d val %v", req.UnitId, req.Addr, req.Quantity, req.Args)
 		args := coilsToBytes(req.Args)
-		b, err := h.conn.WriteMultipleCoilsWithSlave(req.UnitId, req.Addr, req.Quantity, args)
+		b, err := h.conn.Clone(req.UnitId).WriteMultipleCoils(req.Addr, req.Quantity, args)
 		return h.bytesToBoolResult("write coils", req.Quantity, b, err)
 	}
 
 	h.log.TRACE.Printf("read coils: id %d addr %d qty %d", req.UnitId, req.Addr, req.Quantity)
-	b, err := h.conn.ReadCoilsWithSlave(req.UnitId, req.Addr, req.Quantity)
+	b, err := h.conn.Clone(req.UnitId).ReadCoils(req.Addr, req.Quantity)
 	return h.bytesToBoolResult("read coils", req.Quantity, b, err)
 }
 
 func (h *handler) HandleInputRegisters(req *mbserver.InputRegistersRequest) ([]uint16, error) {
 	h.log.TRACE.Printf("read input: id %d addr %d qty %d", req.UnitId, req.Addr, req.Quantity)
-	b, err := h.conn.ReadInputRegistersWithSlave(req.UnitId, req.Addr, req.Quantity)
+	b, err := h.conn.Clone(req.UnitId).ReadInputRegisters(req.Addr, req.Quantity)
 	return h.exceptionToUint16AndError("read input", b, err)
 }
 
@@ -154,16 +154,16 @@ func (h *handler) HandleHoldingRegisters(req *mbserver.HoldingRegistersRequest)
 
 		if req.WriteFuncCode == gridx.FuncCodeWriteSingleRegister {
 			h.log.TRACE.Printf("write holding: id %d addr %d val %04x", req.UnitId, req.Addr, req.Args[0])
-			b, err := h.conn.WriteSingleRegisterWithSlave(req.UnitId, req.Addr, req.Args[0])
+			b, err := h.conn.Clone(req.UnitId).WriteSingleRegister(req.Addr, req.Args[0])
 			return h.exceptionToUint16AndError("write holding", b, err)
 		}
 
 		h.log.TRACE.Printf("write holdings: id %d addr %d qty %d val %0x", req.UnitId, req.Addr, req.Quantity, asBytes(req.Args))
-		b, err := h.conn.WriteMultipleRegistersWithSlave(req.UnitId, req.Addr, req.Quantity, asBytes(req.Args))
+		b, err := h.conn.Clone(req.UnitId).WriteMultipleRegisters(req.Addr, req.Quantity, asBytes(req.Args))
 		return h.exceptionToUint16AndError("write multiple holding", b, err)
 	}
 
 	h.log.TRACE.Printf("read holdings: id %d addr %d qty %d", req.UnitId, req.Addr, req.Quantity)
-	b, err := h.conn.ReadHoldingRegistersWithSlave(req.UnitId, req.Addr, req.Quantity)
+	b, err := h.conn.Clone(req.UnitId).ReadHoldingRegisters(req.Addr, req.Quantity)
 	return h.exceptionToUint16AndError("read holding", b, err)
 }
diff --git a/server/modbus/proxy.go b/server/modbus/proxy.go
index 096b6d7a..12123432 100644
--- a/server/modbus/proxy.go
+++ b/server/modbus/proxy.go
@@ -12,7 +12,7 @@ import (
 )
 
 func StartProxy(port int, config modbus.Settings, readOnly ReadOnlyMode) error {
-	conn, err := modbus.NewConnection(config.URI, config.Device, config.Comset, config.Baudrate, modbus.ProtocolFromRTU(config.RTU), config.ID)
+	conn, err := modbus.NewConnection(config.URI, config.Device, config.Comset, config.Baudrate, config.Protocol(), config.ID)
 	if err != nil {
 		return err
 	}
diff --git a/server/mqtt.go b/server/mqtt.go
index f0f39932..c44a4043 100644
--- a/server/mqtt.go
+++ b/server/mqtt.go
@@ -178,12 +178,12 @@ func (m *MQTT) listenSiteSetters(topic string, site site.API) error {
 		{"/batteryDischargeControl", boolSetter(site.SetBatteryDischargeControl)},
 		{"/prioritySoc", floatSetter(site.SetPrioritySoc)},
 		{"/residualPower", floatSetter(site.SetResidualPower)},
-		{"/smartCostLimit", floatSetter(func(limit float64) error {
+		{"/smartCostLimit", floatPtrSetter(pass(func(limit *float64) {
 			for _, lp := range site.Loadpoints() {
 				lp.SetSmartCostLimit(limit)
 			}
-			return nil
-		})},
+		}))},
+		{"/batteryGridChargeLimit", floatPtrSetter(pass(site.SetBatteryGridChargeLimit))},
 	} {
 		if err := m.Handler.ListenSetter(topic+s.topic, s.fun); err != nil {
 			return err
@@ -203,7 +203,7 @@ func (m *MQTT) listenLoadpointSetters(topic string, site site.API, lp loadpoint.
 		{"/limitEnergy", floatSetter(pass(lp.SetLimitEnergy))},
 		{"/enableThreshold", floatSetter(pass(lp.SetEnableThreshold))},
 		{"/disableThreshold", floatSetter(pass(lp.SetDisableThreshold))},
-		{"/smartCostLimit", floatSetter(pass(lp.SetSmartCostLimit))},
+		{"/smartCostLimit", floatPtrSetter(pass(lp.SetSmartCostLimit))},
 		{"/planEnergy", func(payload string) error {
 			var plan struct {
 				Time  time.Time `json:"time"`
diff --git a/server/mqtt_setter.go b/server/mqtt_setter.go
index a1a648d9..e45cac70 100644
--- a/server/mqtt_setter.go
+++ b/server/mqtt_setter.go
@@ -25,6 +25,18 @@ func floatSetter(set func(float64) error) func(string) error {
 	return setterFunc(parseFloat, set)
 }
 
+func floatPtrSetter(set func(*float64) error) func(string) error {
+	return func(s string) error {
+		var val *float64
+		if f, err := parseFloat(s); err == nil {
+			val = &f
+		} else if s != "" {
+			return err
+		}
+		return set(val)
+	}
+}
+
 func intSetter(set func(int) error) func(string) error {
 	return setterFunc(strconv.Atoi, set)
 }
diff --git a/tariff/config.go b/tariff/config.go
index 34dda811..a1cc7a94 100644
--- a/tariff/config.go
+++ b/tariff/config.go
@@ -1,31 +1,21 @@
 package tariff
 
 import (
+	"context"
 	"fmt"
 	"strings"
 
 	"github.com/evcc-io/evcc/api"
+	reg "github.com/evcc-io/evcc/util/registry"
 )
 
-type tariffRegistry map[string]func(map[string]interface{}) (api.Tariff, error)
+var registry = reg.New[api.Tariff]("tariff")
 
-func (r tariffRegistry) Add(name string, factory func(map[string]interface{}) (api.Tariff, error)) {
-	if _, exists := r[name]; exists {
-		panic(fmt.Sprintf("cannot register duplicate tariff type: %s", name))
-	}
-	r[name] = factory
+// Types returns the list of types
+func Types() []string {
+	return registry.Types()
 }
 
-func (r tariffRegistry) Get(name string) (func(map[string]interface{}) (api.Tariff, error), error) {
-	factory, exists := r[name]
-	if !exists {
-		return nil, fmt.Errorf("invalid tariff type: %s", name)
-	}
-	return factory, nil
-}
-
-var registry tariffRegistry = make(map[string]func(map[string]interface{}) (api.Tariff, error))
-
 // NewFromConfig creates tariff from configuration
 func NewFromConfig(typ string, other map[string]interface{}) (api.Tariff, error) {
 	factory, err := registry.Get(strings.ToLower(typ))
@@ -33,7 +23,7 @@ func NewFromConfig(typ string, other map[string]interface{}) (api.Tariff, error)
 		return nil, err
 	}
 
-	v, err := factory(other)
+	v, err := factory(context.TODO(), other)
 	if err != nil {
 		err = fmt.Errorf("cannot create tariff type '%s': %w", typ, err)
 	}
diff --git a/templates/definition/charger/cfos.yaml b/templates/definition/charger/cfos.yaml
index 6fa06090..e66ff82f 100644
--- a/templates/definition/charger/cfos.yaml
+++ b/templates/definition/charger/cfos.yaml
@@ -7,10 +7,10 @@ capabilities: ["mA", "rfid", "1p3p"]
 requirements:
   description:
     de: |
-      Der Zähler- falls vorhanden- muss separat als Ladezähler konfiguriert werden.
+      Ein evtl. vorhandener S0 Zähler muss separat als Ladezähler konfiguriert werden.
       Phasenumschaltung bietet nur die Solar-Variante und muss vom Anwender freigeschaltet werden.
     en: |
-      The meter- if present- must be configured separately as charge meter.
+      S0 meters must be configured separately as charge meter.
       Phase switching is only available with the Solar variant and must be enabled by the user.
   evcc: ["sponsorship"]
 params:
diff --git a/templates/definition/charger/ocpp-elvi.yaml b/templates/definition/charger/ocpp-elvi.yaml
index dd168c1a..c4263c6f 100644
--- a/templates/definition/charger/ocpp-elvi.yaml
+++ b/templates/definition/charger/ocpp-elvi.yaml
@@ -24,6 +24,6 @@ params:
 render: |
   {{ include "ocpp" . }}
   {{- if ne .meter "false" }}
-  metervalues: Current.Import.L1,Current.Import.L2,Current.Import.L3,Energy.Active.Import.Register,Power.Active.Import,Voltage.L1,Voltage.L2,Voltage.L3
+  metervalues: Current.Import,Energy.Active.Import.Register,Power.Active.Import,Voltage
   meterinterval: {{ .meterinterval }}
   {{- end }}
diff --git a/templates/definition/charger/versicharge.yaml b/templates/definition/charger/versicharge.yaml
index 2100ae86..838dcfe7 100644
--- a/templates/definition/charger/versicharge.yaml
+++ b/templates/definition/charger/versicharge.yaml
@@ -6,8 +6,8 @@ products:
 requirements:
   evcc: ["sponsorship"]
   description:
-    de: Erfordert Firmware >= 2.121.5
-    en: Requires firmware >= 2.121.5
+    de: Erfordert Firmware >= 2.135
+    en: Requires firmware >= 2.135
 params:
   - name: modbus
     choice: ["tcpip"]
diff --git a/templates/definition/meter/goodwe-hybrid.yaml b/templates/definition/meter/goodwe-hybrid.yaml
index 74fe50e7..a2598422 100644
--- a/templates/definition/meter/goodwe-hybrid.yaml
+++ b/templates/definition/meter/goodwe-hybrid.yaml
@@ -8,7 +8,7 @@ params:
   - name: usage
     choice: ["grid", "pv", "battery"]
   - name: modbus
-    choice: ["rs485", "tcpip"]
+    choice: ["rs485", "tcpip", "udp"]
     baudrate: 9600
     id: 247
   - name: capacity
diff --git a/templates/definition/vehicle/zero.yaml b/templates/definition/vehicle/zero.yaml
new file mode 100644
index 00000000..e6083866
--- /dev/null
+++ b/templates/definition/vehicle/zero.yaml
@@ -0,0 +1,10 @@
+template: zero
+products:
+  - brand: Zero Motorcycles
+params:
+  - preset: vehicle-base
+  - preset: vehicle-identify
+render: |
+  type: zero
+  {{ include "vehicle-base" . }}
+  {{ include "vehicle-identify" . }}
diff --git a/util/config/config.go b/util/config/config.go
index 944d5579..ad472f8b 100644
--- a/util/config/config.go
+++ b/util/config/config.go
@@ -1,6 +1,7 @@
 package config
 
 import (
+	"encoding/json"
 	"fmt"
 
 	"github.com/evcc-io/evcc/util/templates"
@@ -8,13 +9,14 @@ import (
 )
 
 type Config struct {
-	ID      int `gorm:"primarykey"`
-	Class   templates.Class
-	Type    string
-	Details []ConfigDetail `gorm:"constraint:OnUpdate:CASCADE,OnDelete:CASCADE"`
+	ID    int `gorm:"primarykey"`
+	Class templates.Class
+	Type  string
+	Value string
 }
 
-type ConfigDetail struct {
+// TODO remove- migration only
+type ConfigDetails struct {
 	ConfigID int    `gorm:"index:idx_unique"`
 	Key      string `gorm:"index:idx_unique"`
 	Value    string
@@ -41,20 +43,17 @@ func (d *Config) Typed() Typed {
 
 // detailsAsMap converts device details to map
 func (d *Config) detailsAsMap() map[string]any {
-	res := make(map[string]any, len(d.Details))
-	for _, detail := range d.Details {
-		res[detail.Key] = detail.Value
+	res := make(map[string]any)
+	if err := json.Unmarshal([]byte(d.Value), &res); err != nil {
+		panic(err)
 	}
 	return res
 }
 
 // detailsFromMap converts map to device details
-func detailsFromMap(config map[string]any) []ConfigDetail {
-	res := make([]ConfigDetail, 0, len(config))
-	for k, v := range config {
-		res = append(res, ConfigDetail{Key: k, Value: fmt.Sprintf("%v", v)})
-	}
-	return res
+func detailsFromMap(config map[string]any) (string, error) {
+	b, err := json.Marshal(config)
+	return string(b), err
 }
 
 // Update updates a config's details to the database
@@ -65,11 +64,11 @@ func (d *Config) Update(conf map[string]any) error {
 			return err
 		}
 
-		if err := tx.Delete(new(ConfigDetail), ConfigDetail{ConfigID: d.ID}).Error; err != nil {
+		val, err := detailsFromMap(conf)
+		if err != nil {
 			return err
 		}
-
-		d.Details = detailsFromMap(conf)
+		d.Value = val
 
 		return tx.Save(&d).Error
 	})
@@ -77,38 +76,71 @@ func (d *Config) Update(conf map[string]any) error {
 
 // Delete deletes a config from the database
 func (d *Config) Delete() error {
-	return db.Transaction(func(tx *gorm.DB) error {
-		if err := tx.Delete(new(ConfigDetail), ConfigDetail{ConfigID: d.ID}).Error; err != nil {
-			return err
-		}
-
-		return tx.Delete(Config{ID: d.ID}).Error
-	})
+	return db.Delete(Config{ID: d.ID}).Error
 }
 
 var db *gorm.DB
 
 func Init(instance *gorm.DB) error {
 	db = instance
+	m := db.Migrator()
 
 	for old, new := range map[string]string{
 		"devices":        "configs",
 		"device_details": "config_details",
 	} {
-		if m := db.Migrator(); m.HasTable(old) {
+		if m.HasTable(old) {
 			if err := m.RenameTable(old, new); err != nil {
 				return err
 			}
 		}
 	}
 
-	err := db.AutoMigrate(new(Config), new(ConfigDetail))
+	err := m.AutoMigrate(new(Config))
+
+	if err == nil && m.HasTable("config_details") {
+		err = m.AutoMigrate(new(ConfigDetails))
 
-	if err == nil && db.Migrator().HasConstraint(new(ConfigDetail), "fk_devices_details") {
-		err = db.Migrator().DropConstraint(new(ConfigDetail), "fk_devices_details")
+		if err == nil && m.HasConstraint(new(ConfigDetails), "fk_devices_details") {
+			err = m.DropConstraint(new(ConfigDetails), "fk_devices_details")
+		}
+		if err == nil && m.HasColumn(new(ConfigDetails), "device_id") {
+			err = m.DropColumn(new(ConfigDetails), "device_id")
+		}
 	}
-	if err == nil && db.Migrator().HasColumn(new(ConfigDetail), "device_id") {
-		err = db.Migrator().DropColumn(new(ConfigDetail), "device_id")
+
+	if err == nil && m.HasTable("config_details") {
+		var devices []Config
+		if err := db.Where(&Config{}).Find(&devices).Error; err != nil {
+			return err
+		}
+
+		// migrate ConfigDetails into Config.Value
+		for _, dev := range devices {
+			var details []ConfigDetails
+			if err := db.Where(&ConfigDetails{ConfigID: dev.ID}).Find(&details).Error; err != nil {
+				return err
+			}
+
+			res := make(map[string]any)
+			for _, detail := range details {
+				res[detail.Key] = detail.Value
+			}
+
+			if len(res) > 0 {
+				val, err := detailsFromMap(res)
+				if err != nil {
+					return err
+				}
+				dev.Value = val
+
+				if err := db.Save(&dev).Error; err != nil {
+					return err
+				}
+			}
+		}
+
+		err = m.DropTable("config_details")
 	}
 
 	return err
@@ -122,12 +154,12 @@ func NameForID(id int) string {
 // ConfigurationsByClass returns devices by class from the database
 func ConfigurationsByClass(class templates.Class) ([]Config, error) {
 	var devices []Config
-	tx := db.Where(&Config{Class: class}).Preload("Details").Order("id").Find(&devices)
+	tx := db.Where(&Config{Class: class}).Find(&devices)
 
 	// remove devices without details
 	res := make([]Config, 0, len(devices))
 	for _, dev := range devices {
-		if len(dev.Details) > 0 {
+		if len(dev.Value) > 0 {
 			res = append(res, dev)
 		}
 	}
@@ -138,21 +170,24 @@ func ConfigurationsByClass(class templates.Class) ([]Config, error) {
 // ConfigByID returns device by id from the database
 func ConfigByID(id int) (Config, error) {
 	var config Config
-	tx := db.Where(&Config{ID: id}).Preload("Details").First(&config)
+	tx := db.Where(&Config{ID: id}).First(&config)
 	return config, tx.Error
 }
 
 // AddConfig adds a new config to the database
 func AddConfig(class templates.Class, typ string, conf map[string]any) (Config, error) {
+	val, err := detailsFromMap(conf)
+	if err != nil {
+		return Config{}, err
+	}
+
 	config := Config{
-		Class:   class,
-		Type:    typ,
-		Details: detailsFromMap(conf),
+		Class: class,
+		Type:  typ,
+		Value: val,
 	}
 
-	err := db.Transaction(func(tx *gorm.DB) error {
-		return tx.Create(&config).Error
-	})
+	err = db.Create(&config).Error
 
 	return config, err
 }
diff --git a/util/modbus/connection.go b/util/modbus/connection.go
new file mode 100644
index 00000000..672a44ff
--- /dev/null
+++ b/util/modbus/connection.go
@@ -0,0 +1,113 @@
+package modbus
+
+import (
+	"sync"
+	"time"
+
+	"github.com/grid-x/modbus"
+	"github.com/volkszaehler/mbmd/meters"
+)
+
+// Connection is a logical modbus connection per slave ID sharing a physical connection
+type Connection struct {
+	meters.Connection
+	mu      sync.Mutex
+	logger  *logger
+	logical meters.Logger
+	delay   time.Duration
+}
+
+func (c *Connection) Delay(delay time.Duration) {
+	c.delay = delay
+}
+
+func (c *Connection) Clone(slaveID uint8) *Connection {
+	return &Connection{
+		Connection: c.Connection.Clone(slaveID),
+		logger:     c.logger,
+	}
+}
+
+// TODO resolve conflicts
+func (c *Connection) ConnectDelay(delay time.Duration) {
+	if delay > 0 {
+		c.Connection.ConnectDelay(delay)
+	}
+}
+
+// TODO resolve conflicts
+func (c *Connection) Timeout(timeout time.Duration) {
+	if timeout > 0 {
+		_ = c.Connection.Timeout(timeout)
+	}
+}
+
+func (c *Connection) Logger(l modbus.Logger) {
+	c.mu.Lock()
+	defer c.mu.Unlock()
+
+	c.logical = l
+}
+
+func (c *Connection) prepare() {
+	c.mu.Lock()
+	defer c.mu.Unlock()
+
+	time.Sleep(c.delay)
+	c.logger.Logger(c.logical)
+}
+
+func (c *Connection) ReadCoils(address, quantity uint16) ([]byte, error) {
+	c.prepare()
+	return c.ModbusClient().ReadCoils(address, quantity)
+}
+
+func (c *Connection) WriteSingleCoil(address, value uint16) ([]byte, error) {
+	c.prepare()
+	return c.ModbusClient().WriteSingleCoil(address, value)
+}
+
+func (c *Connection) ReadInputRegisters(address, quantity uint16) ([]byte, error) {
+	c.prepare()
+	return c.ModbusClient().ReadInputRegisters(address, quantity)
+}
+
+func (c *Connection) ReadHoldingRegisters(address, quantity uint16) ([]byte, error) {
+	c.prepare()
+	return c.ModbusClient().ReadHoldingRegisters(address, quantity)
+}
+
+func (c *Connection) WriteSingleRegister(address, value uint16) ([]byte, error) {
+	c.prepare()
+	return c.ModbusClient().WriteSingleRegister(address, value)
+}
+
+func (c *Connection) WriteMultipleRegisters(address, quantity uint16, value []byte) ([]byte, error) {
+	c.prepare()
+	return c.ModbusClient().WriteMultipleRegisters(address, quantity, value)
+}
+
+func (c *Connection) ReadDiscreteInputs(address, quantity uint16) (results []byte, err error) {
+	c.prepare()
+	return c.ModbusClient().ReadDiscreteInputs(address, quantity)
+}
+
+func (c *Connection) WriteMultipleCoils(address, quantity uint16, value []byte) (results []byte, err error) {
+	c.prepare()
+	return c.ModbusClient().WriteMultipleCoils(address, quantity, value)
+}
+
+func (c *Connection) ReadWriteMultipleRegisters(readAddress, readQuantity, writeAddress, writeQuantity uint16, value []byte) (results []byte, err error) {
+	c.prepare()
+	return c.ModbusClient().ReadWriteMultipleRegisters(readAddress, readQuantity, writeAddress, writeQuantity, value)
+}
+
+func (c *Connection) MaskWriteRegister(address, andMask, orMask uint16) (results []byte, err error) {
+	c.prepare()
+	return c.ModbusClient().MaskWriteRegister(address, andMask, orMask)
+}
+
+func (c *Connection) ReadFIFOQueue(address uint16) (results []byte, err error) {
+	c.prepare()
+	return c.ModbusClient().ReadFIFOQueue(address)
+}
diff --git a/util/modbus/log.go b/util/modbus/log.go
new file mode 100644
index 00000000..59495eb2
--- /dev/null
+++ b/util/modbus/log.go
@@ -0,0 +1,29 @@
+package modbus
+
+import (
+	"sync"
+
+	"github.com/grid-x/modbus"
+	"github.com/volkszaehler/mbmd/meters"
+)
+
+type logger struct {
+	mu     sync.RWMutex
+	logger meters.Logger
+}
+
+func (l *logger) Logger(logger modbus.Logger) {
+	l.mu.Lock()
+	defer l.mu.Unlock()
+
+	l.logger = logger
+}
+
+func (l *logger) Printf(format string, v ...interface{}) {
+	l.mu.RLock()
+	defer l.mu.RUnlock()
+
+	if l.logger != nil {
+		l.logger.Printf(format, v...)
+	}
+}
diff --git a/util/modbus/modbus.go b/util/modbus/modbus.go
index d8833e30..9201e1d3 100644
--- a/util/modbus/modbus.go
+++ b/util/modbus/modbus.go
@@ -5,7 +5,6 @@ import (
 	"fmt"
 	"strings"
 	"sync"
-	"time"
 
 	"github.com/evcc-io/evcc/util"
 	"github.com/volkszaehler/mbmd/meters"
@@ -19,6 +18,7 @@ const (
 	Tcp Protocol = iota
 	Rtu
 	Ascii
+	Udp
 
 	CoilOn uint16 = 0xFF00
 )
@@ -38,9 +38,22 @@ type Settings struct {
 	SubDevice           int
 	URI, Device, Comset string
 	Baudrate            int
+	UDP                 bool
 	RTU                 *bool // indicates RTU over TCP if true
 }
 
+// Protocol identifies the wire format from the RTU setting
+func (s Settings) Protocol() Protocol {
+	switch {
+	case s.UDP:
+		return Udp
+	case s.RTU != nil && *s.RTU:
+		return Rtu
+	default:
+		return Tcp
+	}
+}
+
 func (s *Settings) String() string {
 	if s.URI != "" {
 		return s.URI
@@ -48,180 +61,6 @@ func (s *Settings) String() string {
 	return s.Device
 }
 
-// Connection decorates a meters.Connection with transparent slave id and error handling
-type Connection struct {
-	slaveID uint8
-	mu      sync.Mutex
-	conn    meters.Connection
-	delay   time.Duration
-}
-
-func (mb *Connection) prepare(slaveID uint8) {
-	mb.conn.Slave(slaveID)
-	if mb.delay > 0 {
-		time.Sleep(mb.delay)
-	}
-}
-
-func (mb *Connection) handle(res []byte, err error) ([]byte, error) {
-	if err != nil {
-		mb.conn.Close()
-	}
-	return res, err
-}
-
-// Delay sets delay so use between subsequent modbus operations
-func (mb *Connection) Delay(delay time.Duration) {
-	mb.delay = delay
-}
-
-// ConnectDelay sets the initial delay after connecting before starting communication
-func (mb *Connection) ConnectDelay(delay time.Duration) {
-	mb.conn.ConnectDelay(delay)
-}
-
-// Logger sets logger implementation
-func (mb *Connection) Logger(logger meters.Logger) {
-	mb.conn.Logger(logger)
-}
-
-// Timeout sets the connection timeout (not idle timeout)
-func (mb *Connection) Timeout(timeout time.Duration) {
-	mb.conn.Timeout(timeout)
-}
-
-// ReadCoils wraps the underlying implementation
-func (mb *Connection) ReadCoilsWithSlave(slaveID uint8, address, quantity uint16) ([]byte, error) {
-	mb.mu.Lock()
-	defer mb.mu.Unlock()
-	mb.prepare(slaveID)
-	return mb.handle(mb.conn.ModbusClient().ReadCoils(address, quantity))
-}
-
-// WriteSingleCoil wraps the underlying implementation
-func (mb *Connection) WriteSingleCoilWithSlave(slaveID uint8, address, value uint16) ([]byte, error) {
-	mb.mu.Lock()
-	defer mb.mu.Unlock()
-	mb.prepare(slaveID)
-	return mb.handle(mb.conn.ModbusClient().WriteSingleCoil(address, value))
-}
-
-// ReadInputRegisters wraps the underlying implementation
-func (mb *Connection) ReadInputRegistersWithSlave(slaveID uint8, address, quantity uint16) ([]byte, error) {
-	mb.mu.Lock()
-	defer mb.mu.Unlock()
-	mb.prepare(slaveID)
-	return mb.handle(mb.conn.ModbusClient().ReadInputRegisters(address, quantity))
-}
-
-// ReadHoldingRegisters wraps the underlying implementation
-func (mb *Connection) ReadHoldingRegistersWithSlave(slaveID uint8, address, quantity uint16) ([]byte, error) {
-	mb.mu.Lock()
-	defer mb.mu.Unlock()
-	mb.prepare(slaveID)
-	return mb.handle(mb.conn.ModbusClient().ReadHoldingRegisters(address, quantity))
-}
-
-// WriteSingleRegister wraps the underlying implementation
-func (mb *Connection) WriteSingleRegisterWithSlave(slaveID uint8, address, value uint16) ([]byte, error) {
-	mb.mu.Lock()
-	defer mb.mu.Unlock()
-	mb.prepare(slaveID)
-	return mb.handle(mb.conn.ModbusClient().WriteSingleRegister(address, value))
-}
-
-// WriteMultipleRegisters wraps the underlying implementation
-func (mb *Connection) WriteMultipleRegistersWithSlave(slaveID uint8, address, quantity uint16, value []byte) ([]byte, error) {
-	mb.mu.Lock()
-	defer mb.mu.Unlock()
-	mb.prepare(slaveID)
-	return mb.handle(mb.conn.ModbusClient().WriteMultipleRegisters(address, quantity, value))
-}
-
-// ReadDiscreteInputs wraps the underlying implementation
-func (mb *Connection) ReadDiscreteInputsWithSlave(slaveID uint8, address, quantity uint16) (results []byte, err error) {
-	mb.mu.Lock()
-	defer mb.mu.Unlock()
-	mb.prepare(slaveID)
-	return mb.handle(mb.conn.ModbusClient().ReadDiscreteInputs(address, quantity))
-}
-
-// WriteMultipleCoils wraps the underlying implementation
-func (mb *Connection) WriteMultipleCoilsWithSlave(slaveID uint8, address, quantity uint16, value []byte) (results []byte, err error) {
-	mb.mu.Lock()
-	defer mb.mu.Unlock()
-	mb.prepare(slaveID)
-	return mb.handle(mb.conn.ModbusClient().WriteMultipleCoils(address, quantity, value))
-}
-
-// ReadWriteMultipleRegisters wraps the underlying implementation
-func (mb *Connection) ReadWriteMultipleRegistersWithSlave(slaveID uint8, readAddress, readQuantity, writeAddress, writeQuantity uint16, value []byte) (results []byte, err error) {
-	mb.mu.Lock()
-	defer mb.mu.Unlock()
-	mb.prepare(slaveID)
-	return mb.handle(mb.conn.ModbusClient().ReadWriteMultipleRegisters(readAddress, readQuantity, writeAddress, writeQuantity, value))
-}
-
-// MaskWriteRegister wraps the underlying implementation
-func (mb *Connection) MaskWriteRegisterWithSlave(slaveID uint8, address, andMask, orMask uint16) (results []byte, err error) {
-	mb.mu.Lock()
-	defer mb.mu.Unlock()
-	mb.prepare(slaveID)
-	return mb.handle(mb.conn.ModbusClient().MaskWriteRegister(address, andMask, orMask))
-}
-
-// ReadFIFOQueue wraps the underlying implementation
-func (mb *Connection) ReadFIFOQueueWithSlave(slaveID uint8, address uint16) (results []byte, err error) {
-	mb.mu.Lock()
-	defer mb.mu.Unlock()
-	mb.prepare(slaveID)
-	return mb.handle(mb.conn.ModbusClient().ReadFIFOQueue(address))
-}
-
-func (mb *Connection) ReadCoils(address, quantity uint16) ([]byte, error) {
-	return mb.ReadCoilsWithSlave(mb.slaveID, address, quantity)
-}
-
-func (mb *Connection) WriteSingleCoil(address, value uint16) ([]byte, error) {
-	return mb.WriteSingleCoilWithSlave(mb.slaveID, address, value)
-}
-
-func (mb *Connection) ReadInputRegisters(address, quantity uint16) ([]byte, error) {
-	return mb.ReadInputRegistersWithSlave(mb.slaveID, address, quantity)
-}
-
-func (mb *Connection) ReadHoldingRegisters(address, quantity uint16) ([]byte, error) {
-	return mb.ReadHoldingRegistersWithSlave(mb.slaveID, address, quantity)
-}
-
-func (mb *Connection) WriteSingleRegister(address, value uint16) ([]byte, error) {
-	return mb.WriteSingleRegisterWithSlave(mb.slaveID, address, value)
-}
-
-func (mb *Connection) WriteMultipleRegisters(address, quantity uint16, value []byte) ([]byte, error) {
-	return mb.WriteMultipleRegistersWithSlave(mb.slaveID, address, quantity, value)
-}
-
-func (mb *Connection) ReadDiscreteInputs(address, quantity uint16) (results []byte, err error) {
-	return mb.ReadDiscreteInputsWithSlave(mb.slaveID, address, quantity)
-}
-
-func (mb *Connection) WriteMultipleCoils(address, quantity uint16, value []byte) (results []byte, err error) {
-	return mb.WriteMultipleCoilsWithSlave(mb.slaveID, address, quantity, value)
-}
-
-func (mb *Connection) ReadWriteMultipleRegisters(readAddress, readQuantity, writeAddress, writeQuantity uint16, value []byte) (results []byte, err error) {
-	return mb.ReadWriteMultipleRegistersWithSlave(mb.slaveID, readAddress, readQuantity, writeAddress, writeQuantity, value)
-}
-
-func (mb *Connection) MaskWriteRegister(address, andMask, orMask uint16) (results []byte, err error) {
-	return mb.MaskWriteRegisterWithSlave(mb.slaveID, address, andMask, orMask)
-}
-
-func (mb *Connection) ReadFIFOQueue(address uint16) (results []byte, err error) {
-	return mb.ReadFIFOQueueWithSlave(mb.slaveID, address)
-}
-
 var (
 	connections = make(map[string]meters.Connection)
 	mu          sync.Mutex
@@ -240,65 +79,69 @@ func registeredConnection(key string, newConn meters.Connection) meters.Connecti
 	return newConn
 }
 
-// ProtocolFromRTU identifies the wire format from the RTU setting
-func ProtocolFromRTU(rtu *bool) Protocol {
-	if rtu != nil && *rtu {
-		return Rtu
+// NewConnection creates physical modbus device from config
+func NewConnection(uri, device, comset string, baudrate int, proto Protocol, slaveID uint8) (*Connection, error) {
+	conn, err := physicalConnection(proto, Settings{
+		URI:      uri,
+		Device:   device,
+		Comset:   comset,
+		Baudrate: baudrate,
+	})
+	if err != nil {
+		return nil, err
 	}
-	return Tcp
+
+	res := &Connection{
+		Connection: conn.Clone(slaveID),
+		logger:     new(logger),
+	}
+
+	return res, nil
 }
 
-// NewConnection creates physical modbus device from config
-func NewConnection(uri, device, comset string, baudrate int, proto Protocol, slaveID uint8) (*Connection, error) {
+func physicalConnection(proto Protocol, cfg Settings) (meters.Connection, error) {
 	var conn meters.Connection
 
-	if device != "" && uri != "" {
-		return nil, errors.New("invalid modbus configuration: can only have either uri or device")
+	if (cfg.Device != "") == (cfg.URI != "") {
+		return nil, errors.New("invalid modbus configuration: must have either uri or device")
 	}
 
-	if device != "" {
-		switch strings.ToUpper(comset) {
+	if cfg.Device != "" {
+		switch strings.ToUpper(cfg.Comset) {
 		case "8N1", "8E1", "8N2":
 		case "80":
-			comset = "8E1"
+			cfg.Comset = "8E1"
 		default:
-			return nil, fmt.Errorf("invalid comset: %s", comset)
+			return nil, fmt.Errorf("invalid comset: %s", cfg.Comset)
 		}
 
-		if baudrate == 0 {
+		if cfg.Baudrate == 0 {
 			return nil, errors.New("invalid modbus configuration: need baudrate and comset")
 		}
 
 		if proto == Ascii {
-			conn = registeredConnection(device, meters.NewASCII(device, baudrate, comset))
+			conn = registeredConnection(cfg.Device, meters.NewASCII(cfg.Device, cfg.Baudrate, cfg.Comset))
 		} else {
-			conn = registeredConnection(device, meters.NewRTU(device, baudrate, comset))
+			conn = registeredConnection(cfg.Device, meters.NewRTU(cfg.Device, cfg.Baudrate, cfg.Comset))
 		}
 	}
 
-	if uri != "" {
-		uri = util.DefaultPort(uri, 502)
+	if cfg.URI != "" {
+		cfg.URI = util.DefaultPort(cfg.URI, 502)
 
 		switch proto {
+		case Udp:
+			conn = registeredConnection(cfg.URI, meters.NewRTUOverUDP(cfg.URI))
 		case Rtu:
-			conn = registeredConnection(uri, meters.NewRTUOverTCP(uri))
+			conn = registeredConnection(cfg.URI, meters.NewRTUOverTCP(cfg.URI))
 		case Ascii:
-			conn = registeredConnection(uri, meters.NewASCIIOverTCP(uri))
+			conn = registeredConnection(cfg.URI, meters.NewASCIIOverTCP(cfg.URI))
 		default:
-			conn = registeredConnection(uri, meters.NewTCP(uri))
+			conn = registeredConnection(cfg.URI, meters.NewTCP(cfg.URI))
 		}
 	}
 
-	if conn == nil {
-		return nil, errors.New("invalid modbus configuration: need either uri or device")
-	}
-
-	slaveConn := &Connection{
-		slaveID: slaveID,
-		conn:    conn,
-	}
-
-	return slaveConn, nil
+	return conn, nil
 }
 
 // NewDevice creates physical modbus device from config
diff --git a/util/modbus/mutex.go b/util/modbus/mutex.go
new file mode 100644
index 00000000..4592bddb
--- /dev/null
+++ b/util/modbus/mutex.go
@@ -0,0 +1,13 @@
+package modbus
+
+import "sync"
+
+var mu2 sync.Mutex
+
+func Lock() {
+	mu2.Lock()
+}
+
+func Unlock() {
+	mu2.Unlock()
+}
diff --git a/util/registry/registry.go b/util/registry/registry.go
new file mode 100644
index 00000000..3394f1b3
--- /dev/null
+++ b/util/registry/registry.go
@@ -0,0 +1,49 @@
+package registry
+
+import (
+	"context"
+	"fmt"
+
+	"golang.org/x/exp/maps"
+)
+
+type (
+	factoryFunc[T any] func(context.Context, map[string]any) (T, error)
+
+	registry[T any] struct {
+		typ  string
+		data map[string]factoryFunc[T]
+	}
+)
+
+func (r registry[T]) Add(name string, factory func(map[string]any) (T, error)) {
+	r.AddCtx(name, func(_ context.Context, cc map[string]any) (T, error) {
+		return factory(cc)
+	})
+}
+
+func (r registry[T]) AddCtx(name string, factory factoryFunc[T]) {
+	if _, exists := r.data[name]; exists {
+		panic(fmt.Sprintf("cannot register duplicate %s type: %s", r.typ, name))
+	}
+	r.data[name] = factory
+}
+
+func (r registry[T]) Get(name string) (factoryFunc[T], error) {
+	factory, exists := r.data[name]
+	if !exists {
+		return nil, fmt.Errorf("invalid %s type: %s", r.typ, name)
+	}
+	return factory, nil
+}
+
+func (r registry[T]) Types() []string {
+	return maps.Keys(r.data)
+}
+
+func New[T any](typ string) registry[T] {
+	return registry[T]{
+		typ:  typ,
+		data: make(map[string]factoryFunc[T]),
+	}
+}
diff --git a/util/templates/defaults.yaml b/util/templates/defaults.yaml
index 93982c69..79f29d93 100644
--- a/util/templates/defaults.yaml
+++ b/util/templates/defaults.yaml
@@ -435,6 +435,7 @@ modbus:
   interfaces:
     rs485: ["rs485serial", "rs485tcpip"]
     tcpip: ["tcpip"]
+    udp: ["udp"]
   types:
     rs485serial:
       description:
@@ -476,6 +477,18 @@ modbus:
         - reference: true
           name: port
           default: 502
+    udp:
+      description:
+        generic: UDP
+      params:
+        - reference: true
+          referencename: modbusid
+          name: id
+        - reference: true
+          name: host
+        - reference: true
+          name: port
+          default: 502
 
 devicegroups:
   generic:
diff --git a/util/templates/documentation.go b/util/templates/documentation.go
index 004d7a18..c041af52 100644
--- a/util/templates/documentation.go
+++ b/util/templates/documentation.go
@@ -3,6 +3,7 @@ package templates
 import (
 	"bytes"
 	_ "embed"
+	"slices"
 	"strconv"
 	"strings"
 	"text/template"
@@ -77,6 +78,17 @@ func (t *Template) RenderDocumentation(product Product, lang string) ([]byte, er
 		filteredParams = append(filteredParams, param)
 	}
 
+	// all advanced params should be sorted to the end
+	slices.SortStableFunc(filteredParams, func(i, j Param) int {
+		if i.IsAdvanced() && !j.IsAdvanced() {
+			return 1
+		}
+		if !i.IsAdvanced() && j.IsAdvanced() {
+			return -1
+		}
+		return 0
+	})
+
 	data := map[string]interface{}{
 		"Template":               t.Template,
 		"ProductBrand":           product.Brand,
diff --git a/util/templates/modbus.tpl b/util/templates/modbus.tpl
index aea82255..ea374c4c 100644
--- a/util/templates/modbus.tpl
+++ b/util/templates/modbus.tpl
@@ -13,6 +13,11 @@ rtu: true
 # Modbus TCP
 uri: {{ .host }}:{{ .port }}
 rtu: false
+{{- else if or (eq .modbus "udp") .udp }}
+# Modbus UDP
+uri: {{ .host }}:{{ if (ne .port "502") }}{{ .port }}{{ else }}8899{{ end }}
+udp: true
+rtu: true
 {{- else }}
 # configuration error - should not happen
 modbusConnectionTypeNotDefined: {{ .modbus }}
diff --git a/util/templates/types.go b/util/templates/types.go
index 8a598bb2..c95838f5 100644
--- a/util/templates/types.go
+++ b/util/templates/types.go
@@ -17,9 +17,11 @@ const (
 
 	ModbusChoiceRS485    = "rs485"
 	ModbusChoiceTCPIP    = "tcpip"
+	ModbusChoiceUDP      = "udp"
 	ModbusKeyRS485Serial = "rs485serial"
 	ModbusKeyRS485TCPIP  = "rs485tcpip"
 	ModbusKeyTCPIP       = "tcpip"
+	ModbusKeyUDP         = "udp"
 
 	ModbusParamNameId       = "id"
 	ModbusParamNameDevice   = "device"
@@ -37,7 +39,7 @@ const (
 	RenderModeInstance
 )
 
-var ValidModbusChoices = []string{ModbusChoiceRS485, ModbusChoiceTCPIP}
+var ValidModbusChoices = []string{ModbusChoiceRS485, ModbusChoiceTCPIP, ModbusChoiceUDP}
 
 const (
 	CapabilityISO151182      = "iso151182"       // ISO 15118-2 support
@@ -62,7 +64,7 @@ var predefinedTemplateProperties = []string{
 	"type", "template", "name",
 	ModbusParamNameId, ModbusParamNameDevice, ModbusParamNameBaudrate, ModbusParamNameComset,
 	ModbusParamNameURI, ModbusParamNameHost, ModbusParamNamePort, ModbusParamNameRTU,
-	ModbusKeyTCPIP, ModbusKeyRS485Serial, ModbusKeyRS485TCPIP,
+	ModbusKeyTCPIP, ModbusKeyUDP, ModbusKeyRS485Serial, ModbusKeyRS485TCPIP,
 }
 
 // TextLanguage contains language-specific texts
diff --git a/vehicle/config.go b/vehicle/config.go
index ca692e59..2590013d 100644
--- a/vehicle/config.go
+++ b/vehicle/config.go
@@ -1,12 +1,14 @@
 package vehicle
 
 import (
+	"context"
 	"fmt"
 	"strings"
 	"time"
 
 	"github.com/evcc-io/evcc/api"
 	"github.com/evcc-io/evcc/util"
+	reg "github.com/evcc-io/evcc/util/registry"
 )
 
 const (
@@ -14,32 +16,11 @@ const (
 	interval = 15 * time.Minute // refresh interval when charging
 )
 
-type vehicleRegistry map[string]func(map[string]interface{}) (api.Vehicle, error)
+var registry = reg.New[api.Vehicle]("vehicle")
 
-func (r vehicleRegistry) Add(name string, factory func(map[string]interface{}) (api.Vehicle, error)) {
-	if _, exists := r[name]; exists {
-		panic(fmt.Sprintf("cannot register duplicate vehicle type: %s", name))
-	}
-	r[name] = factory
-}
-
-func (r vehicleRegistry) Get(name string) (func(map[string]interface{}) (api.Vehicle, error), error) {
-	factory, exists := r[name]
-	if !exists {
-		return nil, fmt.Errorf("invalid vehicle type: %s", name)
-	}
-	return factory, nil
-}
-
-var registry vehicleRegistry = make(map[string]func(map[string]interface{}) (api.Vehicle, error))
-
-// Types returns the list of vehicle types
+// Types returns the list of types
 func Types() []string {
-	var res []string
-	for typ := range registry {
-		res = append(res, typ)
-	}
-	return res
+	return registry.Types()
 }
 
 // NewFromConfig creates vehicle from configuration
@@ -63,7 +44,7 @@ func NewFromConfig(typ string, other map[string]interface{}) (api.Vehicle, error
 		return nil, err
 	}
 
-	v, err := factory(cc.Other)
+	v, err := factory(context.TODO(), cc.Other)
 	if err != nil {
 		err = fmt.Errorf("cannot create vehicle type '%s': %w", typ, err)
 	}
diff --git a/vehicle/mercedes/api.go b/vehicle/mercedes/api.go
index 1ba5546d..47acfb02 100644
--- a/vehicle/mercedes/api.go
+++ b/vehicle/mercedes/api.go
@@ -97,6 +97,10 @@ func (v *API) Status(vin string) (StatusResponse, error) {
 		res.EvInfo.Battery.ChargingStatus = 3
 	}
 
+	if val, ok := message.Attributes["maxSoc"]; ok && val != nil {
+		res.EvInfo.Battery.SocLimit = int(val.GetIntValue())
+	}
+
 	if val, ok := message.Attributes["selectedChargeProgram"]; ok && val != nil {
 		selectedChargeProgram := val.GetIntValue()
 		res.EvInfo.Battery.SelectedChargeProgram = int(selectedChargeProgram)
@@ -108,10 +112,7 @@ func (v *API) Status(vin string) (StatusResponse, error) {
 				}
 			}
 		}
-	} else {
-		if val, ok := message.Attributes["maxSoc"]; ok && val != nil {
-			res.EvInfo.Battery.SocLimit = int(val.GetIntValue())
-		}
 	}
+
 	return res, err
 }
diff --git a/vehicle/zero/api.go b/vehicle/zero/api.go
new file mode 100644
index 00000000..69712f7a
--- /dev/null
+++ b/vehicle/zero/api.go
@@ -0,0 +1,71 @@
+package zero
+
+import (
+	"fmt"
+	"net/url"
+
+	"github.com/evcc-io/evcc/util"
+	"github.com/evcc-io/evcc/util/request"
+)
+
+const BaseUrl = "https://mongol.brono.com/mongol/api.php"
+
+// See https://www.electricmotorcycleforum.com/boards/index.php?topic=9520.0
+// API is quite simple
+// 1 Acquire unit id(s) by calling
+// https://mongol.brono.com/mongol/api.php?commandname=get_units&format=json&user=yourusername&pass=yourpass
+// 2 Query last dataset
+// https://mongol.brono.com/mongol/api.php?commandname=get_last_transmit&format=json&user=yourusername&pass=yourpass&unitnumber=0000000
+
+// API is an api.Vehicle implementation for Zero Motorcycles
+type API struct {
+	*request.Helper
+	user     string
+	password string
+}
+
+// NewAPI creates a new vehicle
+func NewAPI(log *util.Logger, user, password string) (*API, error) {
+	var err error
+	v := &API{
+		Helper:   request.NewHelper(log),
+		user:     user,
+		password: password,
+	}
+	return v, err
+}
+
+func (v *API) Vehicles() ([]Unit, error) {
+	var res []Unit
+
+	params := url.Values{
+		"user":        {v.user},
+		"pass":        {v.password},
+		"commandname": {"get_units"},
+		"format":      {"json"},
+	}
+
+	uri := fmt.Sprintf("%s?%s", BaseUrl, params.Encode())
+	err := v.GetJSON(uri, &res)
+	return res, err
+}
+
+// Status implements the /user/vehicles/<vin>/status api
+func (v *API) Status(unitId string) (State, error) {
+	var res []State
+
+	params := url.Values{
+		"user":        {v.user},
+		"pass":        {v.password},
+		"unitnumber":  {unitId},
+		"commandname": {"get_last_transmit"},
+		"format":      {"json"},
+	}
+
+	uri := fmt.Sprintf("%s?%s", BaseUrl, params.Encode())
+	if err := v.GetJSON(uri, &res); err != nil {
+		return State{}, err
+	}
+
+	return res[0], nil
+}
diff --git a/vehicle/zero/provider.go b/vehicle/zero/provider.go
new file mode 100644
index 00000000..05fba1e2
--- /dev/null
+++ b/vehicle/zero/provider.go
@@ -0,0 +1,85 @@
+package zero
+
+import (
+	"time"
+
+	"github.com/evcc-io/evcc/api"
+	"github.com/evcc-io/evcc/provider"
+)
+
+// Provider implements the vehicle api
+type Provider struct {
+	status provider.Cacheable[State]
+}
+
+// NewProvider creates a vehicle api provider
+func NewProvider(api *API, unitId string, cache time.Duration) *Provider {
+	impl := &Provider{
+		status: provider.ResettableCached(func() (State, error) {
+			return api.Status(unitId)
+		}, cache),
+	}
+
+	return impl
+}
+
+var _ api.Battery = (*Provider)(nil)
+
+// Soc implements the api.Vehicle interface
+func (v *Provider) Soc() (float64, error) {
+	res, err := v.status.Get()
+	return float64(res.Soc), err
+}
+
+var _ api.ChargeState = (*Provider)(nil)
+
+// Status implements the api.ChargeState interface
+func (v *Provider) Status() (api.ChargeStatus, error) {
+	res, err := v.status.Get()
+	if err != nil {
+		return api.StatusNone, err
+	}
+
+	status := api.StatusA
+	if res.Pluggedin != 0 {
+		status = api.StatusB
+	}
+	if res.Charging != 0 {
+		status = api.StatusC
+	}
+
+	return status, nil
+}
+
+var _ api.VehicleFinishTimer = (*Provider)(nil)
+
+// FinishTime implements the api.VehicleFinishTimer interface
+func (v *Provider) FinishTime() (time.Time, error) {
+	res, err := v.status.Get()
+	if err != nil {
+		return time.Time{}, err
+	}
+
+	t, err := time.Parse("20060102150405", res.DatetimeActual)
+	if err != nil {
+		t = time.Now()
+	}
+
+	return t.Add(time.Duration(res.Chargingtimeleft) * time.Minute), nil
+}
+
+var _ api.VehicleOdometer = (*Provider)(nil)
+
+// Odometer implements the api.VehicleOdometer interface
+func (v *Provider) Odometer() (float64, error) {
+	res, err := v.status.Get()
+	return res.Mileage, err
+}
+
+var _ api.VehiclePosition = (*Provider)(nil)
+
+// Position implements the api.VehiclePosition interface
+func (v *Provider) Position() (float64, float64, error) {
+	res, err := v.status.Get()
+	return res.Latitude, res.Longitude, err
+}
diff --git a/vehicle/zero/types.go b/vehicle/zero/types.go
new file mode 100644
index 00000000..9dca1103
--- /dev/null
+++ b/vehicle/zero/types.go
@@ -0,0 +1,59 @@
+package zero
+
+type Unit struct {
+	UnitNumber string //"123456",
+	Name       string
+}
+
+type ErrorAnswer struct {
+	Error string
+}
+
+type State struct {
+	Unitnumber       string  //"123456",
+	Name             string  //"538ZFAZ76LCK00000",
+	Unittype         string  //"5",
+	Unitmodel        string  //"6",
+	Mileage          float64 `json:",string"` //"4382.46",
+	Software_version string  //"190430",
+	Logic_state      string  //"2"
+	Reason           string  //"2",
+	Response         string  //"0",
+	Driver           string  //"0",
+	Latitude         float64 // 51.5000,
+	Longitude        float64 // 4.5000,
+	Altitude         string  //:"0",
+	Gps_valid        string  //:"0",
+	Gps_connected    string  //:"1",
+	Satellites       string  //"0",
+	Velocity         string  //"1",
+	Heading          string  //"344",
+	Emergency        string  //:"0",
+	Shock            string  //:"",
+	Ignition         string  //:"0",
+	Door             string  //:"0",
+	Hood             string  //:"0",
+	Volume           string  //:"0",
+	Water_temp       string  //:"",
+	Oil_pressure     string  //:"0",
+	Main_voltage     float64 //:13.08,
+	Analog1          float64 //":"0.09",
+	Analog2          float64 //":"0.09",
+	Analog3          float64 //":"0.09",
+	Siren            string  //:"0",
+	Lock             string  //:"0",
+	Int_lights       string  //:"0",
+	DatetimeUtc      string  `json:"datetime_utc"`    //:"20191030162309",
+	DatetimeActual   string  `json:"datetime_actual"` //:"20191102113548"
+	Address          string  //:"YourCity, YourStreet",
+	Perimeter        string  //:"",
+	Color            int     //:2,
+	Soc              int     //:91,
+	Tipover          int     //:0,
+	Charging         int     //:1,
+	Chargecomplete   int     // 0,
+	Pluggedin        int     //:1,
+	Chargingtimeleft int     //:0
+	Storage          int
+	Battery          int
+}
diff --git a/vehicle/zeromotorcycles.go b/vehicle/zeromotorcycles.go
new file mode 100644
index 00000000..afd7f7d6
--- /dev/null
+++ b/vehicle/zeromotorcycles.go
@@ -0,0 +1,63 @@
+package vehicle
+
+import (
+	"time"
+
+	"github.com/evcc-io/evcc/api"
+	"github.com/evcc-io/evcc/util"
+	"github.com/evcc-io/evcc/vehicle/zero"
+)
+
+// Zero is an api.Vehicle implementation for probably all ZERO Motorcycles
+type ZeroMotorcycle struct {
+	*embed
+	*zero.Provider // provides the api implementations
+}
+
+func init() {
+	registry.Add("zero", NewZeroFromConfig)
+}
+
+// NewZeroFromConfig creates a new vehicle
+func NewZeroFromConfig(other map[string]interface{}) (api.Vehicle, error) {
+	var res *zero.API
+	var err error
+
+	cc := struct {
+		embed               `mapstructure:",squash"`
+		User, Password, VIN string
+		Cache               time.Duration
+	}{
+		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("Zero").Redact(cc.User, cc.Password)
+
+	if res, err = zero.NewAPI(log, cc.User, cc.Password); err != nil {
+		return nil, err
+	}
+
+	vehicle, err := ensureVehicleEx(cc.VIN, res.Vehicles,
+		func(v zero.Unit) (string, error) {
+			return v.Name, nil
+		},
+	)
+	if err != nil {
+		return nil, err
+	}
+
+	v := &ZeroMotorcycle{
+		embed:    &cc.embed,
+		Provider: zero.NewProvider(res, vehicle.UnitNumber, cc.Cache),
+	}
+
+	return v, nil
+}


Test patch:
diff --git a/core/loadpoint_test.go b/core/loadpoint_test.go
index 2c74d474..26fdeef9 100644
--- a/core/loadpoint_test.go
+++ b/core/loadpoint_test.go
@@ -182,7 +182,7 @@ func TestUpdatePowerZero(t *testing.T) {
 		}
 
 		lp.mode = tc.mode
-		lp.Update(0, false, time.Time{}, false, false, 0, nil, nil) // false,sitePower false,0
+		lp.Update(0, nil, false, false, 0, nil, nil) // false,sitePower false,0
 
 		ctrl.Finish()
 	}
@@ -428,7 +428,7 @@ func TestDisableAndEnableAtTargetSoc(t *testing.T) {
 	charger.EXPECT().Status().Return(api.StatusC, nil)
 	charger.EXPECT().Enabled().Return(lp.enabled, nil)
 	charger.EXPECT().MaxCurrent(int64(maxA)).Return(nil)
-	lp.Update(500, false, time.Time{}, false, false, 0, nil, nil)
+	lp.Update(500, nil, false, false, 0, nil, nil)
 	ctrl.Finish()
 
 	t.Log("charging above target - soc deactivates charger")
@@ -437,7 +437,7 @@ func TestDisableAndEnableAtTargetSoc(t *testing.T) {
 	charger.EXPECT().Status().Return(api.StatusC, nil)
 	charger.EXPECT().Enabled().Return(lp.enabled, nil)
 	charger.EXPECT().Enable(false).Return(nil)
-	lp.Update(500, false, time.Time{}, false, false, 0, nil, nil)
+	lp.Update(500, nil, false, false, 0, nil, nil)
 	ctrl.Finish()
 
 	t.Log("deactivated charger changes status to B")
@@ -445,14 +445,14 @@ func TestDisableAndEnableAtTargetSoc(t *testing.T) {
 	vehicle.EXPECT().Soc().Return(95.0, nil)
 	charger.EXPECT().Status().Return(api.StatusB, nil)
 	charger.EXPECT().Enabled().Return(lp.enabled, nil)
-	lp.Update(-5000, false, time.Time{}, false, false, 0, nil, nil)
+	lp.Update(-500, nil, false, false, 0, nil, nil)
 	ctrl.Finish()
 
-	t.Log("soc has fallen below target - soc update prevented by timer")
+	t.Log("soc has risen below target - soc update prevented by timer")
 	clock.Add(5 * time.Minute)
 	charger.EXPECT().Status().Return(api.StatusB, nil)
 	charger.EXPECT().Enabled().Return(lp.enabled, nil)
-	lp.Update(-5000, false, time.Time{}, false, false, 0, nil, nil)
+	lp.Update(-500, nil, false, false, 0, nil, nil)
 	ctrl.Finish()
 
 	t.Log("soc has fallen below target - soc update timer expired")
@@ -460,8 +460,9 @@ func TestDisableAndEnableAtTargetSoc(t *testing.T) {
 	vehicle.EXPECT().Soc().Return(85.0, nil)
 	charger.EXPECT().Status().Return(api.StatusB, nil)
 	charger.EXPECT().Enabled().Return(lp.enabled, nil)
+	charger.EXPECT().MaxCurrent(int64(maxA)).Return(nil)
 	charger.EXPECT().Enable(true).Return(nil)
-	lp.Update(-5000, false, time.Time{}, false, false, 0, nil, nil)
+	lp.Update(-500, nil, false, false, 0, nil, nil)
 	ctrl.Finish()
 }
 
@@ -496,14 +497,14 @@ func TestSetModeAndSocAtDisconnect(t *testing.T) {
 	charger.EXPECT().Enabled().Return(lp.enabled, nil)
 	charger.EXPECT().Status().Return(api.StatusC, nil)
 	charger.EXPECT().MaxCurrent(int64(maxA)).Return(nil)
-	lp.Update(500, false, time.Time{}, false, false, 0, nil, nil)
+	lp.Update(500, nil, false, false, 0, nil, nil)
 
 	t.Log("switch off when disconnected")
 	clock.Add(5 * time.Minute)
 	charger.EXPECT().Enabled().Return(lp.enabled, nil)
 	charger.EXPECT().Status().Return(api.StatusA, nil)
 	charger.EXPECT().Enable(false).Return(nil)
-	lp.Update(-3000, false, time.Time{}, false, false, 0, nil, nil)
+	lp.Update(-300, nil, false, false, 0, nil, nil)
 
 	if mode := lp.GetMode(); mode != api.ModeOff {
 		t.Error("unexpected mode", mode)
@@ -566,14 +567,14 @@ func TestChargedEnergyAtDisconnect(t *testing.T) {
 	rater.EXPECT().ChargedEnergy().Return(0.0, nil)
 	charger.EXPECT().Enabled().Return(lp.enabled, nil)
 	charger.EXPECT().Status().Return(api.StatusC, nil)
-	lp.Update(-1, false, time.Time{}, false, false, 0, nil, nil)
+	lp.Update(-1, nil, false, false, 0, nil, nil)
 
 	t.Log("at 1:00h charging at 5 kWh")
 	clock.Add(time.Hour)
 	rater.EXPECT().ChargedEnergy().Return(5.0, nil)
 	charger.EXPECT().Enabled().Return(lp.enabled, nil)
 	charger.EXPECT().Status().Return(api.StatusC, nil)
-	lp.Update(-1, false, time.Time{}, false, false, 0, nil, nil)
+	lp.Update(-1, nil, false, false, 0, nil, nil)
 	expectCache("chargedEnergy", 5000.0)
 
 	t.Log("at 1:00h stop charging at 5 kWh")
@@ -581,7 +582,7 @@ func TestChargedEnergyAtDisconnect(t *testing.T) {
 	rater.EXPECT().ChargedEnergy().Return(5.0, nil)
 	charger.EXPECT().Enabled().Return(lp.enabled, nil)
 	charger.EXPECT().Status().Return(api.StatusB, nil)
-	lp.Update(-1, false, time.Time{}, false, false, 0, nil, nil)
+	lp.Update(-1, nil, false, false, 0, nil, nil)
 	expectCache("chargedEnergy", 5000.0)
 
 	t.Log("at 1:00h restart charging at 5 kWh")
@@ -589,7 +590,7 @@ func TestChargedEnergyAtDisconnect(t *testing.T) {
 	rater.EXPECT().ChargedEnergy().Return(5.0, nil)
 	charger.EXPECT().Enabled().Return(lp.enabled, nil)
 	charger.EXPECT().Status().Return(api.StatusC, nil)
-	lp.Update(-1, false, time.Time{}, false, false, 0, nil, nil)
+	lp.Update(-1, nil, false, false, 0, nil, nil)
 	expectCache("chargedEnergy", 5000.0)
 
 	t.Log("at 1:30h continue charging at 7.5 kWh")
@@ -597,7 +598,7 @@ func TestChargedEnergyAtDisconnect(t *testing.T) {
 	rater.EXPECT().ChargedEnergy().Return(7.5, nil)
 	charger.EXPECT().Enabled().Return(lp.enabled, nil)
 	charger.EXPECT().Status().Return(api.StatusC, nil)
-	lp.Update(-1, false, time.Time{}, false, false, 0, nil, nil)
+	lp.Update(-1, nil, false, false, 0, nil, nil)
 	expectCache("chargedEnergy", 7500.0)
 
 	t.Log("at 2:00h stop charging at 10 kWh")
@@ -605,7 +606,7 @@ func TestChargedEnergyAtDisconnect(t *testing.T) {
 	rater.EXPECT().ChargedEnergy().Return(10.0, nil)
 	charger.EXPECT().Enabled().Return(lp.enabled, nil)
 	charger.EXPECT().Status().Return(api.StatusB, nil)
-	lp.Update(-1, false, time.Time{}, false, false, 0, nil, nil)
+	lp.Update(-1, nil, false, false, 0, nil, nil)
 	expectCache("chargedEnergy", 10000.0)
 
 	ctrl.Finish()
diff --git a/core/loadpoint_vehicle_test.go b/core/loadpoint_vehicle_test.go
index 3ac7320b..5b846796 100644
--- a/core/loadpoint_vehicle_test.go
+++ b/core/loadpoint_vehicle_test.go
@@ -270,7 +270,7 @@ func TestReconnectVehicle(t *testing.T) {
 			// vehicle not updated yet
 			vehicle.MockChargeState.EXPECT().Status().Return(api.StatusA, nil)
 
-			lp.Update(0, false, time.Time{}, false, false, 0, nil, nil)
+			lp.Update(0, nil, false, false, 0, nil, nil)
 			ctrl.Finish()
 
 			// detection started
@@ -284,7 +284,7 @@ func TestReconnectVehicle(t *testing.T) {
 			// vehicle not updated yet
 			vehicle.MockChargeState.EXPECT().Status().Return(api.StatusB, nil)
 
-			lp.Update(0, false, time.Time{}, false, false, 0, nil, nil)
+			lp.Update(0, nil, false, false, 0, nil, nil)
 			ctrl.Finish()
 
 			// vehicle detected
diff --git a/core/site_battery_test.go b/core/site_battery_test.go
deleted file mode 100644
index a39f0833..00000000
--- a/core/site_battery_test.go
+++ /dev/null
@@ -1,36 +0,0 @@
-package core
-
-import (
-	"testing"
-
-	"github.com/evcc-io/evcc/api"
-	"github.com/evcc-io/evcc/util"
-	"github.com/stretchr/testify/assert"
-	"github.com/stretchr/testify/require"
-	"go.uber.org/mock/gomock"
-)
-
-func TestUpdateBatteryMode(t *testing.T) {
-	expBatMode := api.BatteryHold
-
-	ctrl := gomock.NewController(t)
-
-	batCtrl := struct {
-		*api.MockBatteryController
-		*api.MockMeter
-	}{
-		api.NewMockBatteryController(ctrl),
-		api.NewMockMeter(ctrl),
-	}
-	batCtrl.MockBatteryController.EXPECT().SetBatteryMode(expBatMode).Times(1)
-
-	s := &Site{
-		log:           util.NewLogger("foo"),
-		batteryMeters: []api.Meter{batCtrl},
-		batteryMode:   api.BatteryNormal,
-	}
-
-	err := s.applyBatteryMode(expBatMode)
-	require.NoError(t, err)
-	assert.Equal(t, expBatMode, s.GetBatteryMode())
-}
diff --git a/core/site_test.go b/core/site_test.go
index d03a7801..b0a1a7e9 100644
--- a/core/site_test.go
+++ b/core/site_test.go
@@ -3,7 +3,9 @@ package core
 import (
 	"testing"
 
+	"github.com/evcc-io/evcc/api"
 	"github.com/evcc-io/evcc/util"
+	"github.com/stretchr/testify/assert"
 )
 
 func TestSitePower(t *testing.T) {
@@ -143,3 +145,31 @@ func TestGreenShare(t *testing.T) {
 		}
 	}
 }
+
+func TestRequiredBatteryMode(t *testing.T) {
+	tc := []struct {
+		gridChargeActive bool
+		mode, res        api.BatteryMode
+	}{
+		{false, api.BatteryUnknown, api.BatteryUnknown}, // ignore
+		{false, api.BatteryNormal, api.BatteryUnknown},  // ignore
+		{false, api.BatteryHold, api.BatteryNormal},
+		{false, api.BatteryCharge, api.BatteryNormal},
+
+		{true, api.BatteryUnknown, api.BatteryCharge},
+		{true, api.BatteryNormal, api.BatteryCharge},
+		{true, api.BatteryHold, api.BatteryCharge},
+		{true, api.BatteryCharge, api.BatteryUnknown}, // ignore
+	}
+
+	for _, tc := range tc {
+		t.Logf("%+v", tc)
+
+		s := &Site{
+			batteryMode: tc.mode,
+		}
+
+		res := s.requiredBatteryMode(tc.gridChargeActive, api.Rate{})
+		assert.Equal(t, tc.res, res, "expected %s, got %s", tc.res, res)
+	}
+}
diff --git a/meter/template_test.go b/meter/template_test.go
index 9a4067be..eaf8d134 100644
--- a/meter/template_test.go
+++ b/meter/template_test.go
@@ -13,9 +13,10 @@ var acceptable = []string{
 	"missing token",
 	"mqtt not configured",
 	"not a SunSpec device",
-	"missing credentials", // sockets
-	"power: timeout",      // sockets
-	"missing password",    // Powerwall
+	"connect: connection refused", // sockets
+	"missing credentials",         // sockets
+	"power: timeout",              // sockets
+	"missing password",            // Powerwall
 	"connect: no route to host",
 	"connect: connection refused",
 	"connect: network is unreachable",
diff --git a/server/modbus/proxy_test.go b/server/modbus/proxy_test.go
index 5bd72f7c..84cb382c 100644
--- a/server/modbus/proxy_test.go
+++ b/server/modbus/proxy_test.go
@@ -27,21 +27,21 @@ func TestConcurrentRead(t *testing.T) {
 	require.NoError(t, srv.Start(l))
 	defer func() { _ = srv.Stop() }()
 
-	// client
-	conn, err := modbus.NewConnection(l.Addr().String(), "", "", 0, modbus.Tcp, 1)
-	require.NoError(t, err)
-
 	var wg sync.WaitGroup
 
 	for i := 1; i <= 10; i++ {
 		wg.Add(1)
 
 		go func(id int) {
+			// client
+			conn, err := modbus.NewConnection(l.Addr().String(), "", "", 0, modbus.Tcp, uint8(id))
+			require.NoError(t, err)
+
 			for i := 0; i < 50; i++ {
 				addr := uint16(rand.Int31n(200) + 1)
 				qty := uint16(rand.Int31n(32) + 1)
 
-				b, err := conn.ReadInputRegistersWithSlave(uint8(id), addr, qty)
+				b, err := conn.ReadInputRegisters(addr, qty)
 				require.NoError(t, err)
 
 				if err == nil {
@@ -94,24 +94,24 @@ func TestReadCoils(t *testing.T) {
 		require.NoError(t, err)
 
 		{ // read
-			b, err := conn.ReadCoilsWithSlave(1, 1, 1)
+			b, err := conn.ReadCoils(1, 1)
 			require.NoError(t, err)
 			assert.Equal(t, []byte{0x01}, b)
 
-			b, err = conn.ReadCoilsWithSlave(1, 1, 2)
+			b, err = conn.ReadCoils(1, 2)
 			require.NoError(t, err)
 			assert.Equal(t, []byte{0x03}, b)
 
-			b, err = conn.ReadCoilsWithSlave(1, 1, 9)
+			b, err = conn.ReadCoils(1, 9)
 			require.NoError(t, err)
 			assert.Equal(t, []byte{0xFF, 0x01}, b)
 		}
 		{ // write
-			b, err := conn.WriteSingleCoilWithSlave(1, 1, 0xFF00)
+			b, err := conn.WriteSingleCoil(1, 0xFF00)
 			require.NoError(t, err)
 			assert.Equal(t, []byte{0xFF, 0x00}, b)
 
-			b, err = conn.WriteMultipleCoilsWithSlave(1, 1, 9, []byte{0xFF, 0x01})
+			b, err = conn.WriteMultipleCoils(1, 9, []byte{0xFF, 0x01})
 			require.NoError(t, err)
 			assert.Equal(t, []byte{0x00, 0x09}, b)
 		}
diff --git a/tests/battery-settings.evcc.yaml b/tests/battery-settings.evcc.yaml
new file mode 100755
index 00000000..9dda4dcf
--- /dev/null
+++ b/tests/battery-settings.evcc.yaml
@@ -0,0 +1,71 @@
+interval: 0.1s
+
+site:
+  title: Battery Settings
+  meters:
+    grid: grid
+    pv: pv
+    battery: battery
+
+meters:
+  - name: grid
+    type: custom
+    power:
+      source: const
+      value: 1000
+  - name: pv
+    type: custom
+    power:
+      source: const
+      value: 2000
+  - name: battery
+    type: custom
+    power:
+      source: const
+      value: -1000
+    soc:
+      source: const
+      value: 50
+    capacity: 20
+    batterymode:
+      source: js
+      vm: shared
+      script: |
+        1
+
+loadpoints:
+  - title: Carport
+    charger: charger
+    mode: now
+
+chargers:
+  - name: charger
+    type: custom
+    enable:
+      source: js
+      script:
+    enabled:
+      source: js
+      script: |
+        true
+    status:
+      source: js
+      script: |
+        "C"
+    maxcurrent:
+      source: js
+      script: |
+        16
+
+tariffs:
+  currency: EUR
+  grid:
+    type: fixed
+    price: 0.2 # EUR/kWh
+    zones:
+      - hours: 5-7
+        price: 0.6
+      - hours: 11-14
+        price: 0.05
+      - hours: 17-21
+        price: 0.5
diff --git a/tests/battery-settings.spec.js b/tests/battery-settings.spec.js
new file mode 100644
index 00000000..1b56a83a
--- /dev/null
+++ b/tests/battery-settings.spec.js
@@ -0,0 +1,62 @@
+import { test, expect } from "@playwright/test";
+import { start, stop, baseUrl } from "./evcc";
+
+test.use({ baseURL: baseUrl() });
+
+test.beforeAll(async () => {
+  await start("battery-settings.evcc.yaml", "password.sql");
+});
+test.afterAll(async () => {
+  await stop();
+});
+
+async function enableExperimental(page) {
+  await page.getByTestId("topnavigation-button").click();
+  await page.getByTestId("topnavigation-settings").click();
+  await page.getByLabel("Experimental 🧪").click();
+  await page.getByRole("button", { name: "Close" }).click();
+  await expect(page.locator(".modal-backdrop")).not.toBeVisible();
+}
+
+test.describe("battery settings", async () => {
+  test("open modal", async ({ page }) => {
+    await page.goto("/");
+    await page.getByTestId("topnavigation-button").click();
+    await page.getByTestId("topnavigation-battery").click();
+
+    const modal = page.getByTestId("battery-settings-modal");
+    await expect(modal.getByRole("heading", { name: "Home Battery" })).toBeVisible();
+    await expect(modal.getByRole("link", { name: "Grid charging 🧪" })).not.toBeVisible();
+    await expect(modal).toContainText("Battery level: 50%");
+    await expect(modal).toContainText("10.0 kWh of 20.0 kWh");
+  });
+
+  test("battery usage", async ({ page }) => {
+    await page.goto("/");
+    await page.getByTestId("topnavigation-button").click();
+    await page.getByTestId("topnavigation-battery").click();
+
+    await page.locator("#batterySettingsPriority").selectOption({ label: "50%" });
+    await expect(page.locator("label[for=batterySettingsPriorityMiddle] span")).toHaveText("50%");
+    await expect(page.locator("label[for=batterySettingsPriorityBottom] span")).toHaveText("50%");
+    await page.locator("#batterySettingsBufferTop").selectOption({ label: "80%" });
+    await page.locator("#batterySettingsBufferStart").selectOption({ label: "when above 90%." });
+    await expect(page.locator("label[for=batterySettingsBuffer] span")).toHaveText("80%");
+  });
+
+  test("grid charging", async ({ page }) => {
+    await page.goto("/");
+    await enableExperimental(page);
+    await page.getByTestId("topnavigation-button").click();
+    await page.getByTestId("topnavigation-battery").click();
+
+    await page.getByRole("link", { name: "Grid charging 🧪" }).click();
+    await page.getByLabel("Price limit").selectOption({ label: "≤ 50.0 ct/kWh" });
+    await expect(page.getByTestId("battery-settings-modal")).toContainText("5.0 ct – 50.0 ct");
+    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 expect(page.getByTestId("battery-settings-modal")).toBeVisible();
+  });
+});
diff --git a/tests/config-vehicles.spec.js b/tests/config-vehicles.spec.js
index 442154aa..7607b4a9 100644
--- a/tests/config-vehicles.spec.js
+++ b/tests/config-vehicles.spec.js
@@ -167,4 +167,34 @@ test.describe("vehicles", async () => {
     await expect(vehicleModal.getByLabel("Cache optional")).toBeVisible();
     await expect(vehicleModal.getByLabel("Default mode")).toBeVisible();
   });
+
+  test("save and restore rfid identifiers", async ({ page }) => {
+    await page.goto("/#/config");
+    await login(page);
+    await enableExperimental(page);
+
+    await page.getByTestId("add-vehicle").click();
+    const vehicleModal = page.getByTestId("vehicle-modal");
+
+    // generic
+    await vehicleModal.getByLabel("Manufacturer").selectOption("Generic vehicle");
+    await vehicleModal.getByLabel("Title").fill("RFID Car");
+    await page.getByRole("button", { name: "Show advanced settings" }).click();
+    await vehicleModal.getByLabel("RFID identifiers").fill("aaa\nbbb \n ccc\n\nddd\n");
+    await vehicleModal.getByRole("button", { name: "Validate & save" }).click();
+    await expect(page.getByTestId("restart-needed")).toBeVisible();
+
+    // restart evcc
+    await restart(CONFIG_GRID_ONLY);
+    await page.reload();
+
+    await page
+      .locator(`[data-testid="vehicle"]:has-text("RFID Car")`)
+      .getByRole("button", { name: "edit" })
+      .click();
+    await page.getByRole("button", { name: "Show advanced settings" }).click();
+    await expect(vehicleModal.getByLabel("RFID identifiers")).toHaveValue("aaa\nbbb\nccc\nddd");
+    await page.getByTestId("vehicle-modal").getByLabel("Close").click();
+    await expect(page.getByTestId("fatal-error")).not.toBeVisible();
+  });
 });
diff --git a/util/templates/render_testing.go b/util/templates/render_testing.go
index 5ad7b912..ab6a63b6 100644
--- a/util/templates/render_testing.go
+++ b/util/templates/render_testing.go
@@ -48,6 +48,8 @@ func TestClass(t *testing.T, class Class, instantiate func(t *testing.T, values
 			// we only test one modbus setup
 			if slices.Contains(modbusChoices, ModbusChoiceTCPIP) {
 				values[ModbusKeyTCPIP] = true
+			} else if slices.Contains(modbusChoices, ModbusChoiceUDP) {
+				values[ModbusKeyUDP] = true
 			} else {
 				values[ModbusKeyRS485TCPIP] = true
 			}