Repository: evcc-io/evcc
Pull request #18137 (2025-01-08 19:26:30)

Add loadpoint config api (BC)
Continues https://github.com/evcc-io/evcc/pull/12958. Fix https://github.com/evcc-io/evcc/issues/12903

This PR deprecates the following yaml loadpoint settings:

- mode
- title
- phases
- min/max current
- priority

**Breaking Changes**
Config UI database schema change. Existing db entries will be migrated automatically. Downgrading to an older version will introduce issues.

TODO

- [x] UI for loadpoint properties @naltatis 
- [x] UI for adding/changing charger @naltatis 
- [x] UI for adding/changing lp meter @naltatis 
- [x] e2e tests @naltatis 
- [x] add translations @naltatis 
- [x] UI default vehicle select @naltatis 
- [x] UI circuit assignment @naltatis 
- [x] API for assignable circuits @andig 
- [x] API set meter, charger, defaultVehicle @andig
- [x] UI for adding/removing loadpoints @naltatis 
- [x] API for adding/remove loadpoints @andig 
- [x] implement config as settings 
- [x] unable to save priority=0 @andig 
- [x] remove orphan meters/chargers (no site/loadpoint ref) on startup
- [x] fix unit tests @andig 
- [x] fix initial lp saving issue (logger, settings) @andig
- [x] store loadpoint settings in config (based on https://github.com/evcc-io/evcc/pull/15224) @andig 
- [x] handle pointer values like smartcostlimit
- [x] decide/adapt migration strategy (use `evcc migrate`?) @andig @naltatis 
  - `evcc migrate --reset` removes all ui configured entities (vehicles, meters, chargers, loadpoints)
  - migrating these entities from yaml to ui will be done in a separate issue later
- [x] demo mode solution @naltatis 
- [x] loadpoint settings adapter (config & runtime values) @andig 
- [x] no phase option when single-phase (switch socket)


Patch:
diff --git a/.github/workflows/default.yml b/.github/workflows/default.yml
index 5ebdada5..908d648c 100644
--- a/.github/workflows/default.yml
+++ b/.github/workflows/default.yml
@@ -13,7 +13,7 @@ on:
 jobs:
   clean:
     name: Clean
-    runs-on: ubuntu-latest
+    runs-on: ubuntu-24.04
 
     steps:
       - uses: actions/checkout@v4
@@ -208,14 +208,12 @@ jobs:
       - name: Build Go
         run: make build
 
+      - name: Install Playwright
+        run: npx playwright install --with-deps chromium
+
       - name: Run tests
         run: npx playwright test
 
-      # - name: Run tests
-      #   uses: docker://mcr.microsoft.com/playwright:v1.34.3-jammy
-      #   with:
-      #     args: npx playwright test
-
       - uses: actions/upload-artifact@v4
         if: ${{ !cancelled() }}
         with:
diff --git a/api/api.go b/api/api.go
index 9a01f7de..eb86e40f 100644
--- a/api/api.go
+++ b/api/api.go
@@ -120,7 +120,8 @@ type Authorizer interface {
 	Authorize(key string) error
 }
 
-// PhaseDescriber returns the number of available phases
+// PhaseDescriber returns the number of physically connected phases
+// Used for vehicles and to limit switch sockets to 1p only
 type PhaseDescriber interface {
 	Phases() int
 }
diff --git a/api/error.go b/api/error.go
index 72b07b97..672dddd3 100644
--- a/api/error.go
+++ b/api/error.go
@@ -9,7 +9,7 @@ var ErrNotAvailable = errors.New("not available")
 var ErrMustRetry = errors.New("must retry")
 
 // ErrSponsorRequired indicates that a sponsor token is required
-var ErrSponsorRequired = errors.New("sponsorship required, see https://github.com/evcc-io/evcc#sponsorship")
+var ErrSponsorRequired = errors.New("sponsorship required, see https://docs.evcc.io/docs/sponsorship")
 
 // ErrMissingCredentials indicates that user/password are missing
 var ErrMissingCredentials = errors.New("missing user/password credentials")
diff --git a/api/globalconfig/types.go b/api/globalconfig/types.go
index 611852ce..4e2ce2c0 100644
--- a/api/globalconfig/types.go
+++ b/api/globalconfig/types.go
@@ -38,7 +38,7 @@ type All struct {
 	Vehicles     []config.Named
 	Tariffs      Tariffs
 	Site         map[string]interface{}
-	Loadpoints   []map[string]interface{}
+	Loadpoints   []config.Named
 	Circuits     []config.Named
 }
 
diff --git a/assets/css/app.css b/assets/css/app.css
index 1a83bf9d..3d1f6e98 100644
--- a/assets/css/app.css
+++ b/assets/css/app.css
@@ -25,8 +25,9 @@
 	--evcc-green: #baffcb;
 	--evcc-dark-green: #0fde41ff;
 	--evcc-darker-green: #0ba631ff;
+	--evcc-darker-green-rgb: 11, 166, 49;
 	--evcc-darkest-green: #076f20ff;
-	--evcc-darkest-green-rgb: 11, 166, 49;
+	--evcc-darkest-green-rgb: 7, 111, 32;
 	--evcc-yellow: #faf000;
 	--evcc-dark-yellow: #bbb400;
 	--evcc-orange: #ff9000;
@@ -269,10 +270,16 @@ a:hover {
 	--bs-btn-hover-border-color: var(--bs-gray-medium);
 }
 
-.btn-link:hover {
+.btn-link:hover,
+.btn-link:focus {
 	color: var(--bs-primary);
 }
 
+.btn-link:focus {
+	outline: var(--bs-focus-ring-width) solid var(--bs-focus-ring-color);
+	outline-width: var(--bs-focus-ring-width);
+}
+
 .dark .btn-outline-secondary {
 	--bs-btn-color: var(--bs-gray-bright);
 	--bs-btn-border-color: var(--bs-gray-bright);
@@ -364,6 +371,16 @@ a:hover {
 	transition-timing-function: linear;
 }
 
+.modal.fade-left .modal-dialog {
+	transform: translate(-50px, 0);
+}
+.modal.fade-right .modal-dialog {
+	transform: translate(50px, 0);
+}
+.modal.show .modal-dialog {
+	transform: none;
+}
+
 .modal-header {
 	padding: 0 0 1rem 0;
 	border: none;
@@ -517,8 +534,13 @@ input::-webkit-datetime-edit {
 .form-control {
 	color: var(--evcc-default-text);
 }
-.form-control:disabled {
+.form-control:disabled,
+.form-control:read-only,
+.dark .form-control:disabled,
+.dark .form-control:read-only {
+	background-color: var(--bs-secondary-bg);
 	color: var(--bs-gray-dark);
+	opacity: 1;
 }
 
 .dark .form-control {
@@ -633,6 +655,12 @@ html.app .modal-dialog {
 	color: var(--evcc-darkest-green);
 }
 
+.hyphenate {
+	overflow-wrap: break-word;
+	word-wrap: break-word;
+	hyphens: auto;
+}
+
 input::-webkit-date-and-time-value {
 	text-align: left;
 }
diff --git a/assets/js/components/ChargingPlan.vue b/assets/js/components/ChargingPlan.vue
index d54a0796..b3324c2d 100644
--- a/assets/js/components/ChargingPlan.vue
+++ b/assets/js/components/ChargingPlan.vue
@@ -271,6 +271,7 @@ export default {
 			this.modal.show();
 		},
 		updateTargetTimeLabel: function () {
+			if (!this.effectivePlanTime) return "";
 			const targetDate = new Date(this.effectivePlanTime);
 			this.targetTimeLabel = this.fmtAbsoluteDate(targetDate);
 		},
diff --git a/assets/js/components/Config/ChargerModal.vue b/assets/js/components/Config/ChargerModal.vue
new file mode 100644
index 00000000..ed157780
--- /dev/null
+++ b/assets/js/components/Config/ChargerModal.vue
@@ -0,0 +1,428 @@
+<template>
+	<GenericModal
+		id="chargerModal"
+		:title="modalTitle"
+		data-testid="charger-modal"
+		:fade="fade"
+		@open="open"
+		@close="close"
+	>
+		<form ref="form" class="container mx-0 px-0">
+			<FormRow id="chargerTemplate" :label="$t('config.charger.template')">
+				<select
+					id="chargerTemplate"
+					v-model="templateName"
+					:disabled="!isNew"
+					class="form-select w-100"
+					@change="templateChanged"
+				>
+					<option value="">---</option>
+					<optgroup :label="$t('config.charger.generic')">
+						<option
+							v-for="option in genericOptions"
+							:key="option.name"
+							:value="option.template"
+						>
+							{{ option.name }}
+						</option>
+					</optgroup>
+					<optgroup :label="$t('config.charger.chargers')">
+						<option
+							v-for="option in chargerOptions"
+							:key="option.name"
+							:value="option.template"
+						>
+							{{ option.name }}
+						</option>
+					</optgroup>
+					<optgroup :label="$t('config.charger.switchsocket')">
+						<option
+							v-for="option in switchSocketOptions"
+							:key="option.name"
+							:value="option.template"
+						>
+							{{ option.name }}
+						</option>
+					</optgroup>
+				</select>
+			</FormRow>
+			<p v-if="loadingTemplate">Loading ...</p>
+			<SponsorTokenRequired v-if="sponsorTokenRequired" />
+			<Markdown v-if="description" :markdown="description" class="my-4" />
+			<FormRow
+				v-if="ocppUrl"
+				id="chargerOcppUrl"
+				:label="$t('config.charger.ocppLabel')"
+				:help="$t('config.charger.ocppHelp')"
+			>
+				<input
+					type="text"
+					class="form-control border border-success bg-transparent"
+					:value="ocppUrl"
+					readonly
+				/>
+			</FormRow>
+
+			<Modbus
+				v-if="modbus"
+				v-model:modbus="values.modbus"
+				v-model:id="values.id"
+				v-model:host="values.host"
+				v-model:port="values.port"
+				v-model:device="values.device"
+				v-model:baudrate="values.baudrate"
+				v-model:comset="values.comset"
+				:defaultId="modbus.ID"
+				:defaultComset="modbus.Comset"
+				:defaultBaudrate="modbus.Baudrate"
+				:defaultPort="modbus.Port"
+				:capabilities="modbusCapabilities"
+			/>
+			<PropertyEntry
+				v-for="param in normalParams"
+				:id="`chargerParam${param.Name}`"
+				:key="param.Name"
+				v-bind="param"
+				v-model="values[param.Name]"
+			/>
+
+			<PropertyCollapsible>
+				<template v-if="advancedParams.length" #advanced>
+					<PropertyEntry
+						v-for="param in advancedParams"
+						:id="`chargerParam${param.Name}`"
+						:key="param.Name"
+						v-bind="param"
+						v-model="values[param.Name]"
+					/>
+				</template>
+			</PropertyCollapsible>
+
+			<TestResult
+				v-if="templateName"
+				:success="testSuccess"
+				:failed="testFailed"
+				:unknown="testUnknown"
+				:running="testRunning"
+				:result="testResult"
+				:error="testError"
+				@test="testManually"
+			/>
+
+			<div v-if="templateName" class="my-4 d-flex justify-content-between">
+				<button
+					v-if="isDeletable"
+					type="button"
+					class="btn btn-link text-danger"
+					@click.prevent="remove"
+				>
+					{{ $t("config.general.delete") }}
+				</button>
+				<button
+					v-else
+					type="button"
+					class="btn btn-link text-muted"
+					data-bs-dismiss="modal"
+					tabindex="0"
+				>
+					{{ $t("config.general.cancel") }}
+				</button>
+				<button
+					type="submit"
+					class="btn btn-primary"
+					:disabled="testRunning || saving || sponsorTokenRequired"
+					tabindex="0"
+					@click.prevent="isNew ? create() : update()"
+				>
+					<span
+						v-if="saving"
+						class="spinner-border spinner-border-sm"
+						role="status"
+						aria-hidden="true"
+					></span>
+					{{
+						testUnknown ? $t("config.general.validateSave") : $t("config.general.save")
+					}}
+				</button>
+			</div>
+		</form>
+	</GenericModal>
+</template>
+
+<script>
+import FormRow from "./FormRow.vue";
+import PropertyEntry from "./PropertyEntry.vue";
+import PropertyCollapsible from "./PropertyCollapsible.vue";
+import TestResult from "./TestResult.vue";
+import api from "../../api";
+import test from "./mixins/test";
+import Modbus from "./Modbus.vue";
+import GenericModal from "../GenericModal.vue";
+import Markdown from "./Markdown.vue";
+import SponsorTokenRequired from "./SponsorTokenRequired.vue";
+const initialValues = { type: "template" };
+
+function sleep(ms) {
+	return new Promise((resolve) => setTimeout(resolve, ms));
+}
+
+const CUSTOM_FIELDS = ["modbus"];
+
+export default {
+	name: "ChargerModal",
+	components: {
+		FormRow,
+		PropertyEntry,
+		GenericModal,
+		Modbus,
+		TestResult,
+		PropertyCollapsible,
+		Markdown,
+		SponsorTokenRequired,
+	},
+	mixins: [test],
+	props: {
+		id: Number,
+		name: String,
+		fade: String,
+		isSponsor: Boolean,
+	},
+	emits: ["added", "updated", "removed", "close"],
+	data() {
+		return {
+			isModalVisible: false,
+			templates: [],
+			products: [],
+			templateName: null,
+			template: null,
+			saving: false,
+			selectedType: null,
+			loadingTemplate: false,
+			values: { ...initialValues },
+		};
+	},
+	computed: {
+		modalTitle() {
+			if (this.isNew) {
+				return this.$t(`config.charger.titleAdd`);
+			}
+			return this.$t(`config.charger.titleEdit`);
+		},
+		chargerOptions() {
+			return this.products.filter((p) => !p.group);
+		},
+		genericOptions() {
+			return this.products.filter((p) => p.group === "generic");
+		},
+		switchSocketOptions() {
+			return this.products.filter((p) => p.group === "switchsockets");
+		},
+		templateParams() {
+			const params = this.template?.Params || [];
+			return params.filter((p) => !CUSTOM_FIELDS.includes(p.Name));
+		},
+		normalParams() {
+			return this.templateParams.filter((p) => !p.Advanced);
+		},
+		advancedParams() {
+			return this.templateParams.filter((p) => p.Advanced);
+		},
+		modbus() {
+			const params = this.template?.Params || [];
+			return params.find((p) => p.Name === "modbus");
+		},
+		modbusCapabilities() {
+			return this.modbus?.Choice || [];
+		},
+		ocppUrl() {
+			const isOcpp =
+				this.templateParams.some((p) => p.Name === "connector") &&
+				this.templateParams.some((p) => p.Name === "stationid");
+			if (isOcpp) {
+				return `ws://${window.location.hostname}:8887`;
+			}
+			return null;
+		},
+		modbusDefaults() {
+			const { ID, Comset, Baudrate, Port } = this.modbus || {};
+			return {
+				id: ID,
+				comset: Comset,
+				baudrate: Baudrate,
+				port: Port,
+			};
+		},
+		description() {
+			return this.template?.Requirements?.Description;
+		},
+		sponsorTokenRequired() {
+			const list = this.template?.Requirements?.EVCC || [];
+			return list.includes("sponsorship") && !this.isSponsor;
+		},
+		apiData() {
+			return {
+				template: this.templateName,
+				...this.modbusDefaults,
+				...this.values,
+			};
+		},
+		isNew() {
+			return this.id === undefined;
+		},
+		isDeletable() {
+			return !this.isNew;
+		},
+	},
+	watch: {
+		isModalVisible(visible) {
+			if (visible) {
+				this.templateName = null;
+				this.reset();
+				this.loadProducts();
+				if (this.id !== undefined) {
+					this.loadConfiguration();
+				}
+			}
+		},
+		templateName() {
+			this.loadTemplate();
+		},
+		values: {
+			handler() {
+				this.resetTest();
+			},
+			deep: true,
+		},
+	},
+	methods: {
+		reset() {
+			this.values = { ...initialValues };
+			this.resetTest();
+		},
+		async loadConfiguration() {
+			try {
+				const charger = (await api.get(`config/devices/charger/${this.id}`)).data.result;
+				this.values = charger.config;
+				this.applyDefaultsFromTemplate();
+				this.templateName = this.values.template;
+			} catch (e) {
+				console.error(e);
+			}
+		},
+		async loadProducts() {
+			if (!this.isModalVisible) {
+				return;
+			}
+			try {
+				const opts = { params: { lang: this.$i18n?.locale } };
+				this.products = (await api.get("config/products/charger", opts)).data.result;
+			} catch (e) {
+				console.error(e);
+			}
+		},
+		async loadTemplate() {
+			this.template = null;
+			this.loadingTemplate = true;
+			try {
+				const opts = {
+					params: {
+						lang: this.$i18n?.locale,
+						name: this.templateName,
+					},
+				};
+				const result = await api.get("config/templates/charger", opts);
+				this.template = result.data.result;
+				this.applyDefaultsFromTemplate();
+			} catch (e) {
+				console.error(e);
+			}
+			this.loadingTemplate = false;
+		},
+		applyDefaultsFromTemplate() {
+			const params = this.template?.Params || [];
+			params
+				.filter((p) => p.Default && !this.values[p.Name])
+				.forEach((p) => {
+					this.values[p.Name] = p.Default;
+				});
+		},
+		async create() {
+			if (this.testUnknown) {
+				const success = await this.test(this.testCharger);
+				if (!success) return;
+				await sleep(100);
+			}
+			this.saving = true;
+			try {
+				const response = await api.post("config/devices/charger", this.apiData);
+				const { name } = response.data.result;
+				this.$emit("added", name);
+				this.$emit("updated");
+				this.close();
+			} catch (e) {
+				console.error(e);
+				alert("create failed");
+			}
+			this.saving = false;
+		},
+		async testManually() {
+			await this.test(this.testCharger);
+		},
+		async testCharger() {
+			let url = "config/test/charger";
+			if (!this.isNew) {
+				url += `/merge/${this.id}`;
+			}
+			return await api.post(url, this.apiData);
+		},
+		async update() {
+			if (this.testUnknown) {
+				const success = await this.test(this.testCharger);
+				if (!success) return;
+				await sleep(250);
+			}
+			this.saving = true;
+			try {
+				await api.put(`config/devices/charger/${this.id}`, this.apiData);
+				this.$emit("updated");
+				this.close();
+			} catch (e) {
+				console.error(e);
+				alert("update failed");
+			}
+			this.saving = false;
+		},
+		async remove() {
+			try {
+				await api.delete(`config/devices/charger/${this.id}`);
+				this.$emit("removed", this.name);
+				this.$emit("updated");
+				this.close();
+			} catch (e) {
+				console.error(e);
+				alert("delete failed");
+			}
+		},
+		open() {
+			this.isModalVisible = true;
+		},
+		close() {
+			this.$emit("close");
+			this.isModalVisible = false;
+		},
+		templateChanged() {
+			this.reset();
+		},
+	},
+};
+</script>
+<style scoped>
+.container {
+	margin-left: calc(var(--bs-gutter-x) * -0.5);
+	margin-right: calc(var(--bs-gutter-x) * -0.5);
+	padding-right: 0;
+}
+.addButton {
+	min-height: auto;
+}
+</style>
diff --git a/assets/js/components/Config/ControlModal.vue b/assets/js/components/Config/ControlModal.vue
index 40d4c48f..603e0d31 100644
--- a/assets/js/components/Config/ControlModal.vue
+++ b/assets/js/components/Config/ControlModal.vue
@@ -154,7 +154,4 @@ export default {
 	margin-right: calc(var(--bs-gutter-x) * -0.5);
 	padding-right: 0;
 }
-.btn-cancel {
-	margin-left: -0.75rem;
-}
 </style>
diff --git a/assets/js/components/Config/DeviceCard.vue b/assets/js/components/Config/DeviceCard.vue
index 22f7bdb8..2f436c7f 100644
--- a/assets/js/components/Config/DeviceCard.vue
+++ b/assets/js/components/Config/DeviceCard.vue
@@ -10,6 +10,7 @@
 				type="button"
 				class="btn btn-sm btn-outline-secondary position-relative border-0 p-2"
 				:title="$t('config.main.edit')"
+				tabindex="0"
 				@click="$emit('edit')"
 			>
 				<shopicon-regular-adjust size="s"></shopicon-regular-adjust>
diff --git a/assets/js/components/Config/DeviceTags.vue b/assets/js/components/Config/DeviceTags.vue
index ab9d5e80..1ee6b3ed 100644
--- a/assets/js/components/Config/DeviceTags.vue
+++ b/assets/js/components/Config/DeviceTags.vue
@@ -83,6 +83,9 @@ export default {
 				case "currentRange":
 					return `${this.fmtNumber(value[0], 1)} A / ${this.fmtNumber(value[1], 1)} A`;
 				case "controllable":
+				case "phases1p3p":
+				case "singlePhase":
+				case "enabled":
 				case "configured":
 					return value
 						? this.$t("config.deviceValue.yes")
diff --git a/assets/js/components/Config/FormRow.vue b/assets/js/components/Config/FormRow.vue
index 265d453b..ba68ef92 100644
--- a/assets/js/components/Config/FormRow.vue
+++ b/assets/js/components/Config/FormRow.vue
@@ -11,9 +11,11 @@
 			<slot />
 		</div>
 		<div class="form-text evcc-gray">
-			<div v-if="example">{{ $t("config.form.example") }}: {{ example }}</div>
+			<div v-if="example" class="hyphenate">
+				{{ $t("config.form.example") }}: {{ example }}
+			</div>
 			<div v-if="help">
-				<span class="text-gray" v-html="helpHtml"></span>
+				<span class="text-gray hyphenate" v-html="helpHtml"></span>
 				<a v-if="link" class="ms-1 text-gray" :href="link" target="_blank">
 					{{ $t("config.general.docsLink") }}
 				</a>
diff --git a/assets/js/components/Config/GeneralConfig.vue b/assets/js/components/Config/GeneralConfig.vue
index 9be8d48d..d69c48d0 100644
--- a/assets/js/components/Config/GeneralConfig.vue
+++ b/assets/js/components/Config/GeneralConfig.vue
@@ -7,6 +7,7 @@
 				class="config-button btn btn-link"
 				type="button"
 				:title="$t('config.main.edit')"
+				tabindex="0"
 				@click.prevent="openModal('titleModal')"
 			>
 				<EditIcon size="xs" />
@@ -20,6 +21,7 @@
 				class="config-button btn btn-link"
 				type="button"
 				:title="$t('config.main.edit')"
+				tabindex="0"
 				@click.prevent="openModal('passwordModal')"
 			>
 				<EditIcon size="xs" />
@@ -34,6 +36,7 @@
 				class="config-button btn btn-link"
 				type="button"
 				:title="$t('config.main.edit')"
+				tabindex="0"
 				@click.prevent="openModal('globalSettingsModal')"
 			>
 				<EditIcon size="xs" />
@@ -48,6 +51,7 @@
 				class="config-button btn btn-link"
 				type="button"
 				:title="$t('config.main.edit')"
+				tabindex="0"
 				@click.prevent="openModal('globalSettingsModal')"
 			>
 				<EditIcon size="xs" />
@@ -57,7 +61,7 @@
 			<strong class="config-label">{{ $t("config.sponsor.title") }} 🧪</strong>
 			<div class="config-text" :class="sponsorStatus.cssClass">
 				<span
-					v-if="sponsorStatus.expiresSoon"
+					v-if="sponsorStatus.expiresSoon && sponsorStatus.name"
 					class="d-inline-block me-1 p-1 rounded-circle bg-warning rounded-circle"
 				></span>
 				{{ sponsorStatus.name || "---" }}
@@ -66,6 +70,7 @@
 				class="config-button btn btn-link"
 				type="button"
 				:title="$t('config.main.edit')"
+				tabindex="0"
 				@click.prevent="openModal('sponsorModal')"
 			>
 				<EditIcon size="xs" />
@@ -78,6 +83,7 @@
 				class="config-button btn btn-link"
 				type="button"
 				:title="$t('config.main.edit')"
+				tabindex="0"
 				@click.prevent="openModal('networkModal')"
 			>
 				<EditIcon size="xs" />
@@ -90,6 +96,7 @@
 				class="config-button btn btn-link"
 				type="button"
 				:title="$t('config.main.edit')"
+				tabindex="0"
 				@click.prevent="openModal('controlModal')"
 			>
 				<EditIcon size="xs" />
@@ -136,10 +143,8 @@ export default {
 			const sponsor = store.state?.sponsor || {};
 			const { name, expiresSoon } = sponsor;
 			let cssClass = "";
-			if (expiresSoon) {
-				cssClass = "text-warning";
-			} else if (name) {
-				cssClass = "text-primary";
+			if (name) {
+				cssClass = expiresSoon ? "text-warning" : "text-primary";
 			}
 
 			return { name, expiresSoon, cssClass };
diff --git a/assets/js/components/Config/JsonModal.vue b/assets/js/components/Config/JsonModal.vue
index fbc5c901..beb34acb 100644
--- a/assets/js/components/Config/JsonModal.vue
+++ b/assets/js/components/Config/JsonModal.vue
@@ -168,7 +168,4 @@ export default {
 	margin-right: calc(var(--bs-gutter-x) * -0.5);
 	padding-right: 0;
 }
-.btn-cancel {
-	margin-left: -0.75rem;
-}
 </style>
diff --git a/assets/js/components/Config/LoadpointModal.vue b/assets/js/components/Config/LoadpointModal.vue
new file mode 100644
index 00000000..f2b5f7c0
--- /dev/null
+++ b/assets/js/components/Config/LoadpointModal.vue
@@ -0,0 +1,844 @@
+<template>
+	<GenericModal
+		id="loadpointModal"
+		:title="modalTitle"
+		data-testid="loadpoint-modal"
+		:fade="fade"
+		@open="open"
+		@opened="opened"
+		@close="close"
+	>
+		<form ref="form" class="container mx-0 px-0" @submit.prevent="isNew ? create() : update()">
+			<FormRow
+				id="loadpointParamTitle"
+				:label="$t('config.loadpoint.titleLabel')"
+				:example="$t('config.loadpoint.titleExample')"
+			>
+				<PropertyField
+					id="loadpointParamTitle"
+					v-model="values.title"
+					type="String"
+					class="me-2"
+					required
+				/>
+			</FormRow>
+			<FormRow
+				v-if="values.charger"
+				id="loadpointParamCharger"
+				:label="$t('config.loadpoint.chargerLabel')"
+			>
+				<div class="d-flex">
+					<PropertyField
+						id="loadpointParamCharger"
+						:modelValue="chargerTitle"
+						type="String"
+						class="me-2 flex-grow-1"
+						readonly
+						required
+						@click.prevent="editCharger"
+					/>
+					<button
+						class="btn btn-link btn-sm evcc-default-text"
+						type="button"
+						@click.prevent="editCharger"
+					>
+						<EditIcon />
+					</button>
+				</div>
+			</FormRow>
+			<div v-else class="d-flex justify-content-end">
+				<button
+					class="btn btn-outline-primary"
+					type="submit"
+					:disabled="values.title?.length === 0"
+					@click.prevent="editCharger"
+				>
+					{{ $t("config.loadpoint.addCharger") }}
+				</button>
+			</div>
+			<div v-if="values.charger || !isNew">
+				<FormRow
+					v-if="values.meter"
+					id="loadpointParamMeter"
+					class="mb-6"
+					:label="$t('config.loadpoint.energyMeterLabel')"
+					:help="$t('config.loadpoint.energyMeterHelp')"
+				>
+					<div class="d-flex">
+						<PropertyField
+							id="loadpointParamMeter"
+							:modelValue="meterTitle"
+							type="String"
+							class="me-2 flex-grow-1"
+							required
+							readonly
+							@click="editMeter"
+						/>
+						<button
+							class="btn btn-link btn-sm evcc-default-text"
+							type="button"
+							tabindex="0"
+							@click.prevent="editMeter"
+						>
+							<EditIcon />
+						</button>
+					</div>
+				</FormRow>
+				<p v-else>
+					<button
+						class="btn btn-link btn-sm text-gray px-0"
+						style="margin-top: -1rem"
+						type="button"
+						tabindex="0"
+						@click="editMeter"
+					>
+						{{ $t("config.loadpoint.addMeter") }}
+					</button>
+				</p>
+			</div>
+
+			<div v-if="values.charger">
+				<h6>{{ $t("config.loadpoint.chargingTitle") }}</h6>
+
+				<FormRow
+					id="loadpointMode"
+					:label="$t('config.loadpoint.defaultModeLabel')"
+					:help="
+						values.defaultMode === ''
+							? $t('config.loadpoint.defaultModeHelpKeep')
+							: $t('config.loadpoint.defaultModeHelp')
+					"
+				>
+					<PropertyField
+						id="loadpointMode"
+						v-model="values.defaultMode"
+						type="Choice"
+						class="w-100"
+						required
+						:choice="[
+							{ key: '', name: '---' },
+							{ key: 'off', name: $t('main.mode.off') },
+							{ key: 'pv', name: $t('main.mode.pv') },
+							{ key: 'minpv', name: $t('main.mode.minpv') },
+							{ key: 'now', name: $t('main.mode.now') },
+						]"
+					/>
+				</FormRow>
+
+				<FormRow
+					v-show="showAll"
+					id="loadpointSolarMode"
+					:label="$t('config.loadpoint.solarBehaviorLabel')"
+					:help="
+						solarMode === 'default'
+							? $t('config.loadpoint.solarBehaviorDefaultHelp', {
+									enableDelay: fmtDurationNs(
+										values.thresholds.enable.delay,
+										true,
+										'm'
+									),
+									disableDelay: fmtDurationNs(
+										values.thresholds.disable.delay,
+										true,
+										'm'
+									),
+								})
+							: $t('config.loadpoint.solarBehaviorCustomHelp')
+					"
+				>
+					<SelectGroup
+						id="loadpointSolarMode"
+						v-model="solarMode"
+						class="w-100"
+						:options="[
+							{ name: $t('config.loadpoint.solarModeMaximum'), value: 'default' },
+							{ name: $t('config.loadpoint.solarModeCustom'), value: 'custom' },
+						]"
+						transparent
+						equal-width
+					/>
+				</FormRow>
+
+				<div v-show="solarMode === 'custom'" class="ms-3 mb-5">
+					<div class="mb-4">
+						<div class="d-flex flex-wrap flex-sm-nowrap gap-4">
+							<FormRow
+								id="loadpointEnableThreshold"
+								:label="$t('config.loadpoint.thresholdEnableLabel')"
+								style="margin-bottom: 0 !important"
+							>
+								<PropertyField
+									id="loadpointEnableThreshold"
+									v-model="values.thresholds.enable.threshold"
+									type="Float"
+									unit="W"
+									size="w-25 w-min-200"
+									required
+								/>
+							</FormRow>
+							<FormRow
+								id="loadpointEnableDelay"
+								:label="$t('config.loadpoint.thresholdEnableDelayLabel')"
+								style="margin-bottom: 0 !important"
+							>
+								<PropertyField
+									id="loadpointEnableDelay"
+									v-model="values.thresholds.enable.delay"
+									type="Duration"
+									unit="minute"
+									size="w-25 w-min-200"
+									required
+								/>
+							</FormRow>
+						</div>
+						<div class="form-text evcc-gray">
+							{{
+								values.thresholds.enable.threshold === 0
+									? $t("config.loadpoint.thresholdEnableHelpZero", {
+											delay: fmtDurationNs(
+												values.thresholds.enable.delay,
+												true,
+												"m"
+											),
+										})
+									: values.thresholds.enable.threshold < 0
+										? $t("config.loadpoint.thresholdEnableHelpNegative", {
+												surplus: fmtW(
+													-1 * values.thresholds.enable.threshold,
+													powerUnit.AUTO
+												),
+												delay: fmtDurationNs(
+													values.thresholds.enable.delay,
+													true,
+													"m"
+												),
+											})
+										: $t("config.loadpoint.thresholdEnableHelpInvalid")
+							}}
+						</div>
+					</div>
+
+					<div>
+						<div class="d-flex flex-wrap flex-sm-nowrap gap-4">
+							<FormRow
+								id="loadpointDisableThreshold"
+								:label="$t('config.loadpoint.thresholdDisableLabel')"
+								style="margin-bottom: 0 !important"
+							>
+								<PropertyField
+									id="loadpointDisableThreshold"
+									v-model="values.thresholds.disable.threshold"
+									type="Float"
+									unit="W"
+									size="w-25 w-min-200"
+									required
+								/>
+							</FormRow>
+							<FormRow
+								id="loadpointDisableDelay"
+								:label="$t('config.loadpoint.thresholdDisableDelayLabel')"
+								style="margin-bottom: 0 !important"
+							>
+								<PropertyField
+									id="loadpointDisableDelay"
+									v-model="values.thresholds.disable.delay"
+									type="Duration"
+									unit="minute"
+									size="w-25 w-min-200"
+									required
+								/>
+							</FormRow>
+						</div>
+						<div class="form-text evcc-gray">
+							{{
+								values.thresholds.disable.threshold === 0
+									? $t("config.loadpoint.thresholdDisableHelpZero", {
+											delay: fmtDurationNs(
+												values.thresholds.disable.delay,
+												true,
+												"m"
+											),
+										})
+									: values.thresholds.disable.threshold > 0
+										? $t("config.loadpoint.thresholdDisableHelpPositive", {
+												power: fmtW(
+													values.thresholds.disable.threshold,
+													powerUnit.AUTO
+												),
+												delay: fmtDurationNs(
+													values.thresholds.disable.delay,
+													true,
+													"m"
+												),
+											})
+										: $t("config.loadpoint.thresholdDisableHelpInvalid")
+							}}
+						</div>
+					</div>
+				</div>
+
+				<FormRow
+					v-show="showAll"
+					v-if="showPriority"
+					id="loadpointParamPriority"
+					:label="$t('config.loadpoint.priorityLabel')"
+					:help="$t('config.loadpoint.priorityHelp')"
+				>
+					<SelectGroup
+						id="loadpointParamPriority"
+						v-model="values.priority"
+						class="w-100"
+						:options="priorityOptions"
+						transparent
+					/>
+				</FormRow>
+
+				<h6 v-show="showAll">
+					{{ $t("config.loadpoint.electricalTitle") }}
+					<small class="text-muted">{{
+						$t("config.loadpoint.electricalSubtitle")
+					}}</small>
+				</h6>
+
+				<FormRow
+					id="chargerPower"
+					:label="$t('config.loadpoint.chargerTypeLabel')"
+					:help="
+						chargerPower === '11kw'
+							? $t('config.loadpoint.chargerPower11kwHelp')
+							: chargerPower === '22kw'
+								? $t('config.loadpoint.chargerPower22kwHelp')
+								: $t('config.loadpoint.chargerPowerCustomHelp')
+					"
+				>
+					<SelectGroup
+						id="chargerPower"
+						v-model="chargerPower"
+						class="w-100"
+						:options="[
+							{ name: $t('config.loadpoint.chargerPower11kw'), value: '11kw' },
+							{ name: $t('config.loadpoint.chargerPower22kw'), value: '22kw' },
+							{ name: $t('config.loadpoint.chargerPowerCustom'), value: 'other' },
+						]"
+						transparent
+					/>
+				</FormRow>
+
+				<div v-if="chargerPower === 'other'" class="row ms-3 mb-5">
+					<FormRow
+						id="loadpointMinCurrent"
+						:label="$t('config.loadpoint.minCurrentLabel')"
+						class="col-sm-6 mb-sm-0"
+						:help="values.minCurrent < 6 ? $t('config.loadpoint.minCurrentHelp') : null"
+					>
+						<PropertyField
+							id="loadpointMinCurrent"
+							v-model="values.minCurrent"
+							type="Float"
+							unit="A"
+							size="w-25 w-min-200"
+							class="me-2"
+							required
+						/>
+					</FormRow>
+
+					<FormRow
+						id="loadpointMaxCurrent"
+						:label="$t('config.loadpoint.maxCurrentLabel')"
+						class="col-sm-6 mb-sm-0"
+						:help="
+							values.maxCurrent < values.minCurrent
+								? $t('config.loadpoint.maxCurrentHelp')
+								: null
+						"
+					>
+						<PropertyField
+							id="loadpointMaxCurrent"
+							v-model="values.maxCurrent"
+							type="Float"
+							unit="A"
+							size="w-25 w-min-200"
+							class="me-2"
+							required
+						/>
+					</FormRow>
+				</div>
+
+				<template v-if="!chargerIsSinglePhase">
+					<FormRow
+						v-show="showAll"
+						v-if="chargerSupports1p3p"
+						id="loadpointParamPhases"
+						:label="$t('config.loadpoint.phasesAutomatic')"
+						:help="$t('config.loadpoint.phasesAutomaticHelp')"
+					>
+					</FormRow>
+					<FormRow
+						v-else
+						v-show="showAll"
+						id="loadpointParamPhases"
+						:label="$t('config.loadpoint.phasesLabel')"
+						:help="$t('config.loadpoint.phasesHelp')"
+					>
+						<SelectGroup
+							id="loadpointParamPhases"
+							v-model="values.phases"
+							class="w-100"
+							:options="[
+								{ value: 1, name: $t('config.loadpoint.phases1p') },
+								{ value: 3, name: $t('config.loadpoint.phases3p') },
+							]"
+							transparent
+							equal-width
+						/>
+					</FormRow>
+				</template>
+
+				<FormRow
+					v-show="showAll"
+					v-if="showCircuit"
+					id="loadpointParamCircuit"
+					:label="$t('config.loadpoint.circuitLabel')"
+					:help="$t('config.loadpoint.circuitHelp')"
+				>
+					<PropertyField
+						id="loadpointParamCircuit"
+						v-model="values.circuit"
+						type="Choice"
+						class="me-2"
+						:choice="circuitOptions"
+						required
+					/>
+				</FormRow>
+
+				<h6 v-show="showAll">{{ $t("config.loadpoint.vehiclesTitle") }}</h6>
+
+				<div v-if="vehicleOptions.length">
+					<FormRow
+						id="loadpointParamVehicle"
+						:label="$t('config.loadpoint.vehicleLabel')"
+						:help="
+							values.vehicle
+								? $t('config.loadpoint.vehicleHelpDefault')
+								: $t('config.loadpoint.vehicleHelpAutoDetection')
+						"
+					>
+						<PropertyField
+							id="loadpointParamVehicle"
+							v-model="values.vehicle"
+							type="Choice"
+							class="me-2"
+							:choice="allVehicleOptions"
+							required
+						/>
+					</FormRow>
+
+					<FormRow
+						v-show="showAll"
+						id="loadpointPollMode"
+						:label="$t('config.loadpoint.pollModeLabel')"
+						:help="
+							values.soc.poll.mode === 'charging'
+								? $t('config.loadpoint.pollModeChargingHelp')
+								: values.soc.poll.mode === 'connected'
+									? $t('config.loadpoint.pollModeConnectedHelp')
+									: values.soc.poll.mode === 'always'
+										? $t('config.loadpoint.pollModeAlwaysHelp')
+										: null
+						"
+					>
+						<SelectGroup
+							id="loadpointPollMode"
+							v-model="values.soc.poll.mode"
+							class="w-100"
+							:options="[
+								{
+									value: 'charging',
+									name: $t('config.loadpoint.pollModeCharging'),
+								},
+								{
+									value: 'connected',
+									name: $t('config.loadpoint.pollModeConnected'),
+								},
+								{ value: 'always', name: $t('config.loadpoint.pollModeAlways') },
+							]"
+							transparent
+						/>
+					</FormRow>
+					<FormRow
+						v-show="showAll"
+						v-if="values.soc.poll.mode !== 'charging'"
+						id="loadpointPollInterval"
+						class="ms-3 mb-5"
+						:label="$t('config.loadpoint.pollIntervalLabel')"
+						:help="$t('config.loadpoint.pollIntervalHelp')"
+					>
+						<PropertyField
+							id="loadpointPollInterval"
+							v-model="values.soc.poll.interval"
+							type="Duration"
+							unit="minute"
+							size="w-25 w-min-200"
+							class="me-2"
+							required
+						/>
+					</FormRow>
+
+					<div v-show="showAll" class="d-flex mb-4">
+						<input
+							id="loadpointEstimate"
+							v-model="values.soc.estimate"
+							class="form-check-input"
+							type="checkbox"
+						/>
+						<label class="form-check-label ms-2" for="loadpointEstimate">
+							{{ $t("config.loadpoint.estimateLabel") }}
+						</label>
+					</div>
+				</div>
+				<div v-else v-show="showAll">
+					<p class="text-muted">{{ $t("config.loadpoint.noVehicles") }}</p>
+				</div>
+			</div>
+
+			<button
+				v-if="!showAll && values.charger"
+				class="btn btn-link btn-sm text-gray px-0 border-0 d-flex align-items-center mb-2"
+				type="button"
+				tabindex="0"
+				@click="showAllSelected = true"
+			>
+				{{ $t("config.loadpoint.showAllSettings") }}
+			</button>
+
+			<div v-if="values.charger" class="mt-5 mb-4 d-flex justify-content-between">
+				<button
+					v-if="isDeletable"
+					type="button"
+					class="btn btn-link text-danger"
+					@click.prevent="remove"
+				>
+					{{ $t("config.meter.delete") }}
+				</button>
+				<button
+					v-else
+					type="button"
+					class="btn btn-link text-muted btn-cancel"
+					data-bs-dismiss="modal"
+				>
+					{{ $t("config.loadpoint.cancel") }}
+				</button>
+				<button type="submit" class="btn btn-primary" :disabled="saving">
+					<span
+						v-if="saving"
+						class="spinner-border spinner-border-sm"
+						role="status"
+						aria-hidden="true"
+					></span>
+					{{ $t("config.loadpoint.save") }}
+				</button>
+			</div>
+		</form>
+	</GenericModal>
+</template>
+
+<script>
+import FormRow from "./FormRow.vue";
+import PropertyField from "./PropertyField.vue";
+import SelectGroup from "../SelectGroup.vue";
+import api from "../../api";
+import GenericModal from "../GenericModal.vue";
+import deepClone from "../../utils/deepClone";
+import deepEqual from "../../utils/deepEqual";
+import formatter, { POWER_UNIT } from "../../mixins/formatter";
+import EditIcon from "../MaterialIcon/Edit.vue";
+
+const nsPerMin = 60 * 1e9;
+
+const defaultValues = {
+	title: "",
+	phases: 3,
+	minCurrent: 6,
+	maxCurrent: 16,
+	priority: 0,
+	defaultMode: "",
+	thresholds: {
+		enable: { delay: 1 * nsPerMin, threshold: 0 },
+		disable: { delay: 3 * nsPerMin, threshold: 0 },
+	},
+	soc: {
+		poll: { mode: "charging", interval: 60 * nsPerMin },
+		estimate: false,
+	},
+	vehicle: "",
+	charger: "",
+	circuit: "",
+	meter: "",
+};
+
+const defaultThresholds = {
+	enable: { delay: 1 * nsPerMin, threshold: 0 },
+	disable: { delay: 3 * nsPerMin, threshold: 0 },
+};
+
+export default {
+	name: "LoadpointModal",
+	components: { FormRow, PropertyField, GenericModal, SelectGroup, EditIcon },
+	mixins: [formatter],
+	props: {
+		id: Number,
+		name: String,
+		vehicleOptions: { type: Array, default: () => [] },
+		loadpointCount: Number,
+		fade: String,
+		chargers: { type: Array, default: () => [] },
+		chargerValues: { type: Object, default: () => {} },
+		meters: { type: Array, default: () => [] },
+		circuits: { type: Array, default: () => [] },
+	},
+	emits: ["updated", "openMeterModal", "openChargerModal", "close", "opened"],
+	data() {
+		return {
+			isModalVisible: false,
+			saving: false,
+			selectedType: null,
+			values: deepClone(defaultValues),
+			chargerPower: "11kw",
+			solarMode: "default",
+			tab: "solar",
+			showAllSelected: false,
+			powerUnit: POWER_UNIT,
+		};
+	},
+	computed: {
+		modalTitle() {
+			if (this.isNew) {
+				return this.$t(`config.loadpoint.titleAdd`);
+			}
+			return this.$t(`config.loadpoint.titleEdit`);
+		},
+		isNew() {
+			return this.id === undefined;
+		},
+		showAll() {
+			return !this.isNew || this.showAllSelected;
+		},
+		charger() {
+			return this.chargers.find((c) => c.name === this.values.charger);
+		},
+		chargerTitle() {
+			if (!this.charger) return "";
+			const title = this.charger.config?.template || "unknown";
+			return `${title} [${this.values.charger}]`;
+		},
+		chargerStatus() {
+			if (!this.chargerValues || !this.values.charger) {
+				return {};
+			}
+			return this.chargerValues[this.values.charger] || {};
+		},
+		chargerSupports1p3p() {
+			return this.chargerStatus.phases1p3p?.value || false;
+		},
+		chargerIsSinglePhase() {
+			return this.chargerStatus.singlePhase?.value || false;
+		},
+		meterTitle() {
+			const name = this.values.meter;
+			if (!name) return "";
+			const meter = this.meters.find((m) => m.name === name);
+			const title = meter?.config?.template || "unknown";
+			return `${title} [${name}]`;
+		},
+		isDeletable() {
+			return !this.isNew;
+		},
+		showPriority() {
+			return this.priorityOptions.length > 1;
+		},
+		priorityOptions() {
+			const maxPriority = this.loadpointCount + (this.isNew ? 1 : 0);
+			const result = Array.from({ length: maxPriority }, (_, i) => ({
+				value: i,
+				name: `${i}`,
+			}));
+			return result;
+		},
+		showCircuit() {
+			return this.circuits.length > 0;
+		},
+		circuitOptions() {
+			const options = this.circuits.map((c) => ({
+				key: c.name,
+				name: `${c.config?.title || ""} [${c.name}]`.trim(),
+			}));
+			return [{ key: "", name: "unassigned" }, ...options];
+		},
+		allVehicleOptions() {
+			return [
+				{ key: "", name: this.$t("config.loadpoint.vehicleAutoDetection") },
+				{ key: null, name: null },
+				...this.vehicleOptions,
+			];
+		},
+	},
+	watch: {
+		isModalVisible(visible) {
+			if (visible) {
+				if (this.values?.id !== this.id) {
+					// loadpoint changed
+					this.reset();
+					if (this.id) {
+						this.loadConfiguration();
+					}
+				}
+			}
+		},
+		chargerPower(value) {
+			if (value === "11kw") {
+				this.values.minCurrent = 6;
+				this.values.maxCurrent = 16;
+			} else if (value === "22kw") {
+				this.values.minCurrent = 6;
+				this.values.maxCurrent = 32;
+			}
+		},
+		solarMode(value) {
+			if (value === "default") {
+				this.values.thresholds = deepClone(defaultThresholds);
+			}
+		},
+		chargerSupports1p3p() {
+			this.updatePhases();
+		},
+		chargerIsSinglePhase() {
+			this.updatePhases();
+		},
+	},
+	methods: {
+		reset() {
+			this.values = deepClone(defaultValues);
+			this.updatePhases();
+		},
+		async loadConfiguration() {
+			try {
+				const res = await api.get(`config/loadpoints/${this.id}`);
+				this.values = deepClone(res.data.result);
+				this.updateChargerPower();
+				this.updateSolarMode();
+				this.updatePhases();
+			} catch (e) {
+				console.error(e);
+			}
+		},
+		async update() {
+			this.saving = true;
+			try {
+				const values = deepClone(this.values);
+				await api.put(`config/loadpoints/${this.id}`, values);
+				this.$emit("updated");
+				this.close();
+			} catch (e) {
+				console.error(e);
+				alert("update failed");
+			}
+			this.saving = false;
+		},
+		async remove() {
+			try {
+				await api.delete(`config/loadpoints/${this.id}`);
+				this.$emit("updated");
+				this.close();
+			} catch (e) {
+				console.error(e);
+				alert("delete failed");
+			}
+		},
+		async create() {
+			this.saving = true;
+			try {
+				await api.post("config/loadpoints", this.values);
+				this.reset();
+				this.$emit("updated");
+				this.close();
+			} catch (e) {
+				console.error(e);
+				const error = e.response?.data?.error;
+				alert(`failed to create loadpoint: ${error}`);
+			}
+			this.saving = false;
+		},
+		open() {
+			this.isModalVisible = true;
+		},
+		opened() {
+			this.$emit("opened");
+		},
+		close() {
+			this.$emit("close");
+			this.showAllSelected = false;
+			this.isModalVisible = false;
+		},
+		editCharger() {
+			this.$emit("openChargerModal", this.values.charger);
+		},
+		editMeter() {
+			this.$emit("openMeterModal", this.values.meter);
+		},
+		// called externally
+		setMeter(meter) {
+			this.values.meter = meter;
+		},
+		// called externally
+		setCharger(charger) {
+			this.values.charger = charger;
+		},
+		updateChargerPower() {
+			const { minCurrent, maxCurrent } = this.values;
+			if (minCurrent === 6 && maxCurrent === 16) {
+				this.chargerPower = "11kw";
+			} else if (minCurrent === 6 && maxCurrent === 32) {
+				this.chargerPower = "22kw";
+			} else {
+				this.chargerPower = "other";
+			}
+		},
+		updateSolarMode() {
+			const { thresholds } = this.values;
+			if (deepEqual(thresholds, defaultThresholds)) {
+				this.solarMode = "default";
+			} else {
+				this.solarMode = "custom";
+			}
+		},
+		updatePhases() {
+			const { phases } = this.values;
+			if (this.chargerIsSinglePhase) {
+				this.values.phases = 1;
+				return;
+			}
+			if (this.chargerSupports1p3p && this.isNew) {
+				this.values.phases = 0; // automatic
+				return;
+			}
+			if (!this.chargerSupports1p3p && phases === 0) {
+				this.values.phases = 3; // no automatic switching, default to 3-phase
+				return;
+			}
+		},
+	},
+};
+</script>
+<style scoped>
+.container {
+	margin-left: calc(var(--bs-gutter-x) * -0.5);
+	margin-right: calc(var(--bs-gutter-x) * -0.5);
+	padding-right: 0;
+}
+.addButton {
+	min-height: auto;
+}
+h6 {
+	margin-top: 4rem;
+}
+</style>
diff --git a/assets/js/components/Config/MeterModal.vue b/assets/js/components/Config/MeterModal.vue
index a421d05a..cf129fc3 100644
--- a/assets/js/components/Config/MeterModal.vue
+++ b/assets/js/components/Config/MeterModal.vue
@@ -1,159 +1,137 @@
 <template>
-	<Teleport to="body">
-		<div
-			id="meterModal"
-			ref="modal"
-			class="modal fade text-dark"
-			data-bs-backdrop="true"
-			tabindex="-1"
-			role="dialog"
-			aria-hidden="true"
-			data-testid="meter-modal"
-		>
-			<div class="modal-dialog modal-dialog-centered" role="document">
-				<div class="modal-content">
-					<div class="modal-header">
-						<h5 class="modal-title">
-							{{ modalTitle }}
-						</h5>
-						<button
-							type="button"
-							class="btn-close"
-							data-bs-dismiss="modal"
-							aria-label="Close"
-						></button>
-					</div>
-					<div class="modal-body">
-						<div v-if="!meterType">
-							<AddDeviceButton
-								title="Add solar meter"
-								class="mb-4 addButton"
-								@click="selectType('pv')"
-							/>
-							<AddDeviceButton
-								title="Add battery meter"
-								class="addButton"
-								@click="selectType('battery')"
-							/>
-						</div>
-						<form v-else ref="form" class="container mx-0 px-0">
-							<FormRow id="meterTemplate" :label="$t('config.meter.template')">
-								<select
-									id="meterTemplate"
-									v-model="templateName"
-									:disabled="!isNew"
-									class="form-select w-100"
-									@change="templateChanged"
-								>
-									<option
-										v-for="option in genericOptions"
-										:key="option.name"
-										:value="option.template"
-									>
-										{{ option.name }}
-									</option>
-									<option v-if="genericOptions.length" disabled>
-										──────────
-									</option>
-									<option
-										v-for="option in templateOptions"
-										:key="option.name"
-										:value="option.template"
-									>
-										{{ option.name }}
-									</option>
-								</select>
-							</FormRow>
-							<p v-if="loadingTemplate">Loading ...</p>
-							<Markdown v-if="description" :markdown="description" class="my-4" />
-							<Modbus
-								v-if="modbus"
-								v-model:modbus="values.modbus"
-								v-model:id="values.id"
-								v-model:host="values.host"
-								v-model:port="values.port"
-								v-model:device="values.device"
-								v-model:baudrate="values.baudrate"
-								v-model:comset="values.comset"
-								:defaultId="modbus.ID"
-								:defaultComset="modbus.Comset"
-								:defaultBaudrate="modbus.Baudrate"
-								:defaultPort="modbus.Port"
-								:capabilities="modbusCapabilities"
-							/>
-							<PropertyEntry
-								v-for="param in normalParams"
-								:id="`meterParam${param.Name}`"
-								:key="param.Name"
-								v-bind="param"
-								v-model="values[param.Name]"
-							/>
+	<GenericModal
+		id="meterModal"
+		:title="modalTitle"
+		data-testid="meter-modal"
+		:fade="fade"
+		@open="open"
+		@close="close"
+	>
+		<div v-if="!meterType">
+			<NewDeviceButton
+				title="Add solar meter"
+				class="mb-4 addButton"
+				@click="selectType('pv')"
+			/>
+			<NewDeviceButton
+				title="Add battery meter"
+				class="addButton"
+				@click="selectType('battery')"
+			/>
+		</div>
+		<form v-else ref="form" class="container mx-0 px-0">
+			<FormRow id="meterTemplate" :label="$t('config.meter.template')">
+				<select
+					id="meterTemplate"
+					v-model="templateName"
+					:disabled="!isNew"
+					class="form-select w-100"
+					@change="templateChanged"
+				>
+					<option
+						v-for="option in genericOptions"
+						:key="option.name"
+						:value="option.template"
+					>
+						{{ option.name }}
+					</option>
+					<option v-if="genericOptions.length" disabled>──────────</option>
+					<option
+						v-for="option in templateOptions"
+						:key="option.name"
+						:value="option.template"
+					>
+						{{ option.name }}
+					</option>
+				</select>
+			</FormRow>
+			<p v-if="loadingTemplate">Loading ...</p>
+			<Markdown v-if="description" :markdown="description" class="my-4" />
+			<Modbus
+				v-if="modbus"
+				v-model:modbus="values.modbus"
+				v-model:id="values.id"
+				v-model:host="values.host"
+				v-model:port="values.port"
+				v-model:device="values.device"
+				v-model:baudrate="values.baudrate"
+				v-model:comset="values.comset"
+				:defaultId="modbus.ID"
+				:defaultComset="modbus.Comset"
+				:defaultBaudrate="modbus.Baudrate"
+				:defaultPort="modbus.Port"
+				:capabilities="modbusCapabilities"
+			/>
+			<PropertyEntry
+				v-for="param in normalParams"
+				:id="`meterParam${param.Name}`"
+				:key="param.Name"
+				v-bind="param"
+				v-model="values[param.Name]"
+			/>
 
-							<PropertyCollapsible>
-								<template v-if="advancedParams.length" #advanced>
-									<PropertyEntry
-										v-for="param in advancedParams"
-										:id="`meterParam${param.Name}`"
-										:key="param.Name"
-										v-bind="param"
-										v-model="values[param.Name]"
-									/>
-								</template>
-							</PropertyCollapsible>
+			<PropertyCollapsible>
+				<template v-if="advancedParams.length" #advanced>
+					<PropertyEntry
+						v-for="param in advancedParams"
+						:id="`meterParam${param.Name}`"
+						:key="param.Name"
+						v-bind="param"
+						v-model="values[param.Name]"
+					/>
+				</template>
+			</PropertyCollapsible>
 
-							<TestResult
-								v-if="templateName"
-								:success="testSuccess"
-								:failed="testFailed"
-								:unknown="testUnknown"
-								:running="testRunning"
-								:result="testResult"
-								:error="testError"
-								@test="testManually"
-							/>
+			<TestResult
+				v-if="templateName"
+				:success="testSuccess"
+				:failed="testFailed"
+				:unknown="testUnknown"
+				:running="testRunning"
+				:result="testResult"
+				:error="testError"
+				@test="testManually"
+			/>
 
-							<div v-if="templateName" class="my-4 d-flex justify-content-between">
-								<button
-									v-if="isDeletable"
-									type="button"
-									class="btn btn-link text-danger"
-									@click.prevent="remove"
-								>
-									{{ $t("config.meter.delete") }}
-								</button>
-								<button
-									v-else
-									type="button"
-									class="btn btn-link text-muted"
-									data-bs-dismiss="modal"
-								>
-									{{ $t("config.meter.cancel") }}
-								</button>
-								<button
-									type="submit"
-									class="btn btn-primary"
-									:disabled="testRunning || saving"
-									@click.prevent="isNew ? create() : update()"
-								>
-									<span
-										v-if="saving"
-										class="spinner-border spinner-border-sm"
-										role="status"
-										aria-hidden="true"
-									></span>
-									{{
-										testUnknown
-											? $t("config.meter.validateSave")
-											: $t("config.meter.save")
-									}}
-								</button>
-							</div>
-						</form>
-					</div>
-				</div>
+			<div v-if="templateName" class="my-4 d-flex justify-content-between">
+				<button
+					v-if="isDeletable"
+					type="button"
+					class="btn btn-link text-danger"
+					tabindex="0"
+					@click.prevent="remove"
+				>
+					{{ $t("config.general.delete") }}
+				</button>
+				<button
+					v-else
+					type="button"
+					class="btn btn-link text-muted"
+					data-bs-dismiss="modal"
+					tabindex="0"
+				>
+					{{ $t("config.general.cancel") }}
+				</button>
+				<button
+					type="submit"
+					class="btn btn-primary"
+					:disabled="testRunning || saving"
+					tabindex="0"
+					@click.prevent="isNew ? create() : update()"
+				>
+					<span
+						v-if="saving"
+						class="spinner-border spinner-border-sm"
+						role="status"
+						aria-hidden="true"
+					></span>
+					{{
+						testUnknown ? $t("config.general.validateSave") : $t("config.general.save")
+					}}
+				</button>
 			</div>
-		</div>
-	</Teleport>
+		</form>
+	</GenericModal>
 </template>
 
 <script>
@@ -163,8 +141,9 @@ import PropertyCollapsible from "./PropertyCollapsible.vue";
 import TestResult from "./TestResult.vue";
 import api from "../../api";
 import test from "./mixins/test";
-import AddDeviceButton from "./AddDeviceButton.vue";
+import NewDeviceButton from "./NewDeviceButton.vue";
 import Modbus from "./Modbus.vue";
+import GenericModal from "../GenericModal.vue";
 import Markdown from "./Markdown.vue";
 
 const initialValues = { type: "template" };
@@ -180,9 +159,10 @@ export default {
 	components: {
 		FormRow,
 		PropertyEntry,
+		GenericModal,
 		Modbus,
 		TestResult,
-		AddDeviceButton,
+		NewDeviceButton,
 		PropertyCollapsible,
 		Markdown,
 	},
@@ -191,8 +171,9 @@ export default {
 		id: Number,
 		name: String,
 		type: String,
+		fade: String,
 	},
-	emits: ["added", "updated", "removed"],
+	emits: ["added", "updated", "removed", "close"],
 	data() {
 		return {
 			isModalVisible: false,
@@ -279,9 +260,9 @@ export default {
 	watch: {
 		isModalVisible(visible) {
 			if (visible) {
+				this.templateName = null;
 				this.selectedType = null;
 				this.reset();
-				this.resetTest();
 				this.loadProducts();
 				if (this.id !== undefined) {
 					this.loadConfiguration();
@@ -301,14 +282,6 @@ export default {
 			deep: true,
 		},
 	},
-	mounted() {
-		this.$refs.modal.addEventListener("show.bs.modal", this.modalVisible);
-		this.$refs.modal.addEventListener("hide.bs.modal", this.modalInvisible);
-	},
-	unmounted() {
-		this.$refs.modal?.removeEventListener("show.bs.modal", this.modalVisible);
-		this.$refs.modal?.removeEventListener("hide.bs.modal", this.modalInvisible);
-	},
 	methods: {
 		reset() {
 			this.values = { ...initialValues };
@@ -332,6 +305,7 @@ export default {
 				const opts = {
 					params: {
 						usage: this.meterType,
+						lang: this.$i18n?.locale,
 					},
 				};
 				this.products = (await api.get("config/products/meter", opts)).data.result;
@@ -340,6 +314,7 @@ export default {
 			}
 		},
 		async loadTemplate() {
+			if (!this.templateName) return;
 			this.template = null;
 			this.loadingTemplate = true;
 			try {
@@ -377,7 +352,7 @@ export default {
 				const { name } = response.data.result;
 				this.$emit("added", this.meterType, name);
 				this.$emit("updated");
-				this.modalInvisible();
+				this.close();
 			} catch (e) {
 				console.error(e);
 				alert("create failed");
@@ -404,7 +379,7 @@ export default {
 			try {
 				await api.put(`config/devices/meter/${this.id}`, this.apiData);
 				this.$emit("updated");
-				this.modalInvisible();
+				this.close();
 			} catch (e) {
 				console.error(e);
 				alert("update failed");
@@ -416,16 +391,17 @@ export default {
 				await api.delete(`config/devices/meter/${this.id}`);
 				this.$emit("removed", this.meterType, this.name);
 				this.$emit("updated");
-				this.modalInvisible();
+				this.close();
 			} catch (e) {
 				console.error(e);
 				alert("delete failed");
 			}
 		},
-		modalVisible() {
+		open() {
 			this.isModalVisible = true;
 		},
-		modalInvisible() {
+		close() {
+			this.$emit("close");
 			this.isModalVisible = false;
 		},
 		selectType(type) {
diff --git a/assets/js/components/Config/Modbus.vue b/assets/js/components/Config/Modbus.vue
index 56a88841..684f0a3e 100644
--- a/assets/js/components/Config/Modbus.vue
+++ b/assets/js/components/Config/Modbus.vue
@@ -17,6 +17,7 @@
 				class="btn-check"
 				name="modbusConnection"
 				value="tcpip"
+				tabindex="0"
 				autocomplete="off"
 			/>
 			<label class="btn btn-outline-primary" for="modbusTcpIp">Network</label>
@@ -27,6 +28,7 @@
 				class="btn-check"
 				name="modbusConnection"
 				value="serial"
+				tabindex="0"
 				autocomplete="off"
 			/>
 			<label class="btn btn-outline-primary" for="modbusSerial">Serial / USB</label>
@@ -84,6 +86,7 @@
 					class="btn-check"
 					name="modbusProtocol"
 					value="tcp"
+					tabindex="0"
 					autocomplete="off"
 				/>
 				<label class="btn btn-outline-primary" for="modbusTcp">TCP</label>
@@ -94,6 +97,7 @@
 					class="btn-check"
 					name="modbusProtocol"
 					value="rtu"
+					tabindex="0"
 					autocomplete="off"
 				/>
 				<label class="btn btn-outline-primary" for="modbusRtu">RTU</label>
diff --git a/assets/js/components/Config/NetworkModal.vue b/assets/js/components/Config/NetworkModal.vue
index cfb9c38b..d438ac14 100644
--- a/assets/js/components/Config/NetworkModal.vue
+++ b/assets/js/components/Config/NetworkModal.vue
@@ -21,6 +21,7 @@
 						type="radio"
 						class="btn-check"
 						name="networkSchema"
+						tabindex="0"
 						value="http"
 						autocomplete="off"
 					/>
@@ -31,6 +32,7 @@
 						type="radio"
 						class="btn-check"
 						name="networkSchema"
+						tabindex="0"
 						value="https"
 						autocomplete="off"
 					/>
diff --git a/assets/js/components/Config/AddDeviceButton.vue b/assets/js/components/Config/NewDeviceButton.vue
similarity index 75%
rename from assets/js/components/Config/AddDeviceButton.vue
rename to assets/js/components/Config/NewDeviceButton.vue
index cf657c8a..6e7437c4 100644
--- a/assets/js/components/Config/AddDeviceButton.vue
+++ b/assets/js/components/Config/NewDeviceButton.vue
@@ -1,6 +1,10 @@
 <template>
 	<li class="root">
-		<button class="d-flex align-items-center justify-content-center p-3" @click="$emit('add')">
+		<button
+			class="d-flex align-items-center justify-content-center p-3"
+			tabindex="0"
+			@click="$emit('click')"
+		>
 			<shopicon-regular-plus class="me-1"></shopicon-regular-plus>
 			{{ title }}
 		</button>
@@ -11,11 +15,11 @@
 import "@h2d2/shopicons/es/regular/plus";
 
 export default {
-	name: "AddDeviceButton",
+	name: "NewDeviceButton",
 	props: {
 		title: String,
 	},
-	emits: ["add"],
+	emits: ["click"],
 };
 </script>
 
@@ -30,7 +34,8 @@ export default {
 	padding: 1rem 1rem 0.5rem;
 	transition: border-color var(--evcc-transition-fast) linear;
 }
-.root:hover {
+.root:hover,
+.root:focus-within {
 	border-color: var(--evcc-default-text);
 	color: var(--evcc-default-text);
 }
@@ -42,4 +47,7 @@ button {
 	width: 100%;
 	color: inherit;
 }
+button:focus {
+	outline: none;
+}
 </style>
diff --git a/assets/js/components/Config/PropertyCollapsible.vue b/assets/js/components/Config/PropertyCollapsible.vue
index 218f382f..f2250cb8 100644
--- a/assets/js/components/Config/PropertyCollapsible.vue
+++ b/assets/js/components/Config/PropertyCollapsible.vue
@@ -4,6 +4,7 @@
 			class="btn btn-link btn-sm text-gray px-0 border-0 d-flex align-items-center mb-2"
 			:class="open ? 'text-primary' : ''"
 			type="button"
+			tabindex="0"
 			@click="toggle"
 		>
 			<span v-if="open">{{ $t("config.general.hideAdvancedSettings") }}</span>
diff --git a/assets/js/components/Config/PropertyField.vue b/assets/js/components/Config/PropertyField.vue
index ac3f1533..dbeddea7 100644
--- a/assets/js/components/Config/PropertyField.vue
+++ b/assets/js/components/Config/PropertyField.vue
@@ -147,15 +147,15 @@ export default {
 			return null;
 		},
 		unitValue() {
+			if (this.type === "Duration") {
+				return this.fmtDurationUnit(this.value, this.unit);
+			}
 			if (this.unit) {
 				return this.unit;
 			}
 			if (this.property === "capacity") {
 				return "kWh";
 			}
-			if (this.type === "Duration") {
-				return this.fmtSecondUnit(this.value);
-			}
 			return null;
 		},
 		icons() {
@@ -173,6 +173,9 @@ export default {
 		select() {
 			return this.choice.length > 0;
 		},
+		durationFactor() {
+			return this.unit === "minute" ? 60 : 1;
+		},
 		selectOptions() {
 			// If the valid values are already in the correct format, return them
 			if (typeof this.choice[0] === "object") {
@@ -211,7 +214,7 @@ export default {
 				}
 
 				if (this.type === "Duration" && typeof this.modelValue === "number") {
-					return this.modelValue / NS_PER_SECOND;
+					return this.modelValue / this.durationFactor / NS_PER_SECOND;
 				}
 
 				return this.modelValue;
@@ -228,7 +231,7 @@ export default {
 				}
 
 				if (this.type === "Duration" && typeof newValue === "number") {
-					newValue = newValue * NS_PER_SECOND;
+					newValue = newValue * this.durationFactor * NS_PER_SECOND;
 				}
 
 				this.$emit("update:modelValue", newValue);
diff --git a/assets/js/components/Config/SponsorModal.vue b/assets/js/components/Config/SponsorModal.vue
index b66d482e..b9335754 100644
--- a/assets/js/components/Config/SponsorModal.vue
+++ b/assets/js/components/Config/SponsorModal.vue
@@ -18,14 +18,23 @@
 			<div class="mt-4 mb-3">
 				<Sponsor v-bind="sponsor" />
 			</div>
-			<button
-				v-if="!showForm"
-				type="button"
-				class="btn btn-link text-muted btn-form"
-				@click="showForm = !showForm"
-			>
-				{{ sponsorTokenLabel }}
-			</button>
+			<div v-if="!showForm" class="d-flex gap-1 justify-content-between flex-wrap">
+				<button
+					type="button"
+					class="btn btn-link text-muted text-truncate"
+					@click="showForm = !showForm"
+				>
+					{{ sponsorTokenLabel }}
+				</button>
+				<a
+					v-if="!hasToken"
+					class="btn btn-link text-muted text-truncate"
+					:href="trialTokenLink"
+					target="_blank"
+				>
+					{{ $t("config.sponsor.trialToken") }}
+				</a>
+			</div>
 			<div v-else>
 				<hr />
 				<FormRow
@@ -57,6 +66,7 @@ import FormRow from "./FormRow.vue";
 import Sponsor, { VICTRON_DEVICE } from "../Sponsor.vue";
 import SponsorTokenExpires from "../SponsorTokenExpires.vue";
 import store from "../../store";
+import { docsPrefix } from "../../i18n";
 
 export default {
 	name: "SponsorModal",
@@ -78,6 +88,9 @@ export default {
 				? this.$t("config.sponsor.changeToken")
 				: this.$t("config.sponsor.addToken");
 		},
+		trialTokenLink() {
+			return `${docsPrefix()}/docs/sponsorship#trial`;
+		},
 	},
 	methods: {
 		transformReadValues() {
@@ -90,7 +103,4 @@ export default {
 textarea {
 	font-family: var(--bs-font-monospace);
 }
-.btn-form {
-	margin-left: -0.75rem;
-}
 </style>
diff --git a/assets/js/components/Config/SponsorTokenRequired.vue b/assets/js/components/Config/SponsorTokenRequired.vue
new file mode 100644
index 00000000..fbd34665
--- /dev/null
+++ b/assets/js/components/Config/SponsorTokenRequired.vue
@@ -0,0 +1,21 @@
+<template>
+	<div class="alert alert-warning my-4">
+		{{ $t("config.sponsor.tokenRequired") }}
+		<a href="#" role="button" class="text-warning" @click.prevent="openModal">
+			{{ $t("config.sponsor.tokenRequiredLearnMore") }}
+		</a>
+	</div>
+</template>
+
+<script>
+import Modal from "bootstrap/js/dist/modal";
+
+export default {
+	name: "SponsorTokenRequired",
+	methods: {
+		openModal() {
+			Modal.getOrCreateInstance(document.getElementById("sponsorModal")).show();
+		},
+	},
+};
+</script>
diff --git a/assets/js/components/Config/VehicleModal.vue b/assets/js/components/Config/VehicleModal.vue
index ec4d6947..6e774283 100644
--- a/assets/js/components/Config/VehicleModal.vue
+++ b/assets/js/components/Config/VehicleModal.vue
@@ -1,260 +1,235 @@
 <template>
-	<Teleport to="body">
-		<div
-			id="vehicleModal"
-			ref="modal"
-			class="modal fade text-dark"
-			data-bs-backdrop="true"
-			tabindex="-1"
-			role="dialog"
-			aria-hidden="true"
-			data-testid="vehicle-modal"
-		>
-			<div class="modal-dialog modal-dialog-centered" role="document">
-				<div class="modal-content">
-					<div class="modal-header">
-						<h5 class="modal-title">
-							{{ $t(`config.vehicle.${isNew ? "titleAdd" : "titleEdit"}`) }}
-						</h5>
-						<button
-							type="button"
-							class="btn-close"
-							data-bs-dismiss="modal"
-							aria-label="Close"
-						></button>
-					</div>
-					<div class="modal-body">
-						<form ref="form" class="container mx-0 px-0">
-							<FormRow id="vehicleTemplate" :label="$t('config.vehicle.template')">
-								<select
-									id="vehicleTemplate"
-									v-model="templateName"
-									:disabled="!isNew"
-									class="form-select w-100"
-									@change="templateChanged"
-								>
-									<option value="offline">
-										{{ $t("config.vehicle.offline") }}
-									</option>
-									<option disabled>----------</option>
-									<optgroup :label="$t('config.vehicle.online')">
-										<option
-											v-for="option in templateOptions.online"
-											:key="option.name"
-											:value="option.template"
-										>
-											{{ option.name }}
-										</option>
-									</optgroup>
-									<optgroup :label="$t('config.vehicle.scooter')">
-										<option
-											v-for="option in templateOptions.scooter"
-											:key="option.name"
-											:value="option.template"
-										>
-											{{ option.name }}
-										</option>
-									</optgroup>
-									<optgroup :label="$t('config.vehicle.generic')">
-										<option
-											v-for="option in templateOptions.generic"
-											:key="option.name"
-											:value="option.template"
-										>
-											{{ option.name }}
-										</option>
-									</optgroup>
-								</select>
-							</FormRow>
-							<p v-if="loadingTemplate">Loading ...</p>
-							<Markdown v-if="description" :markdown="description" class="my-4" />
-							<PropertyEntry
-								v-for="param in normalParams"
-								:id="`vehicleParam${param.Name}`"
-								:key="param.Name"
-								v-bind="param"
-								v-model="values[param.Name]"
-							/>
-
-							<PropertyCollapsible>
-								<template v-if="advancedParams.length" #advanced>
-									<PropertyEntry
-										v-for="param in advancedParams"
-										:id="`vehicleParam${param.Name}`"
-										:key="param.Name"
-										v-bind="param"
-										v-model="values[param.Name]"
-									/>
-								</template>
-								<template #more>
-									<h6 class="mt-3">Charging settings</h6>
-									<FormRow
-										id="vehicleParamMode"
-										label="Default mode"
-										help="Charging point mode when connecting this vehicle."
-									>
-										<PropertyField
-											id="vehicleParamMode"
-											v-model="values.mode"
-											type="Choice"
-											class="w-100"
-											:choice="[
-												{ key: 'off', name: $t('main.mode.off') },
-												{ key: 'pv', name: $t('main.mode.pv') },
-												{ key: 'minpv', name: $t('main.mode.minpv') },
-												{ key: 'now', name: $t('main.mode.now') },
-											]"
-										/>
-									</FormRow>
-									<FormRow
-										id="vehicleParamPhases"
-										label="Maximum phases"
-										help="How many phases can this vehicle charge with? Used to calculate required minimum solar surplus and plan duration."
-									>
-										<SelectGroup
-											id="vehicleParamPhases"
-											v-model="values.phases"
-											class="w-100"
-											:options="[
-												{ name: '1-phase', value: '1' },
-												{ name: '2-phases', value: '2' },
-												{ name: '3-phases', value: undefined },
-											]"
-											transparent
-											equal-width
-										/>
-									</FormRow>
-									<div class="row mb-3">
-										<FormRow
-											id="vehicleParamMinCurrent"
-											label="Minimum current"
-											class="col-sm-6 mb-sm-0"
-											:help="
-												values.minCurrent && values.minCurrent < 6
-													? 'Only go below 6 A if you know what you\'re doing.'
-													: null
-											"
-										>
-											<PropertyField
-												id="vehicleParamMinCurrent"
-												v-model="values.minCurrent"
-												type="Float"
-												unit="A"
-												size="w-25 w-min-200"
-												class="me-2"
-											/>
-										</FormRow>
+	<GenericModal
+		id="vehicleModal"
+		:title="$t(`config.vehicle.${isNew ? 'titleAdd' : 'titleEdit'}`)"
+		data-testid="vehicle-modal"
+		@open="open"
+		@closed="closed"
+	>
+		<form ref="form" class="container mx-0 px-0">
+			<FormRow id="vehicleTemplate" :label="$t('config.vehicle.template')">
+				<select
+					id="vehicleTemplate"
+					v-model="templateName"
+					:disabled="!isNew"
+					class="form-select w-100"
+					@change="templateChanged"
+				>
+					<option value="offline">
+						{{ $t("config.vehicle.offline") }}
+					</option>
+					<option disabled>----------</option>
+					<optgroup :label="$t('config.vehicle.online')">
+						<option
+							v-for="option in templateOptions.online"
+							:key="option.name"
+							:value="option.template"
+						>
+							{{ option.name }}
+						</option>
+					</optgroup>
+					<optgroup :label="$t('config.vehicle.scooter')">
+						<option
+							v-for="option in templateOptions.scooter"
+							:key="option.name"
+							:value="option.template"
+						>
+							{{ option.name }}
+						</option>
+					</optgroup>
+					<optgroup :label="$t('config.vehicle.generic')">
+						<option
+							v-for="option in templateOptions.generic"
+							:key="option.name"
+							:value="option.template"
+						>
+							{{ option.name }}
+						</option>
+					</optgroup>
+				</select>
+			</FormRow>
+			<p v-if="loadingTemplate">Loading ...</p>
+			<Markdown v-if="description" :markdown="description" class="my-4" />
+			<PropertyEntry
+				v-for="param in normalParams"
+				:id="`vehicleParam${param.Name}`"
+				:key="param.Name"
+				v-bind="param"
+				v-model="values[param.Name]"
+			/>
 
-										<FormRow
-											id="vehicleParamMaxCurrent"
-											label="Maximum current"
-											class="col-sm-6 mb-sm-0"
-											:help="
-												values.minCurrent &&
-												values.maxCurrent &&
-												values.maxCurrent < values.minCurrent
-													? 'Must be greater than minimum current.'
-													: null
-											"
-										>
-											<PropertyField
-												id="vehicleParamMaxCurrent"
-												v-model="values.maxCurrent"
-												type="Float"
-												unit="A"
-												size="w-25 w-min-200"
-												class="me-2"
-											/>
-										</FormRow>
-									</div>
+			<PropertyCollapsible>
+				<template v-if="advancedParams.length" #advanced>
+					<PropertyEntry
+						v-for="param in advancedParams"
+						:id="`vehicleParam${param.Name}`"
+						:key="param.Name"
+						v-bind="param"
+						v-model="values[param.Name]"
+					/>
+				</template>
+				<template #more>
+					<h6 class="mt-3">Charging settings</h6>
+					<FormRow
+						id="vehicleParamMode"
+						label="Default mode"
+						help="Charging point mode when connecting this vehicle."
+					>
+						<PropertyField
+							id="vehicleParamMode"
+							v-model="values.mode"
+							type="Choice"
+							class="w-100"
+							:choice="[
+								{ key: 'off', name: $t('main.mode.off') },
+								{ key: 'pv', name: $t('main.mode.pv') },
+								{ key: 'minpv', name: $t('main.mode.minpv') },
+								{ key: 'now', name: $t('main.mode.now') },
+							]"
+						/>
+					</FormRow>
+					<FormRow
+						id="vehicleParamPhases"
+						label="Maximum phases"
+						help="How many phases can this vehicle charge with? Used to calculate required minimum solar surplus and plan duration."
+					>
+						<SelectGroup
+							id="vehicleParamPhases"
+							v-model="values.phases"
+							class="w-100"
+							:options="[
+								{ name: '1-phase', value: '1' },
+								{ name: '2-phases', value: '2' },
+								{ name: '3-phases', value: undefined },
+							]"
+							equal-width
+							transparent
+						/>
+					</FormRow>
+					<div class="row mb-3">
+						<FormRow
+							id="vehicleParamMinCurrent"
+							label="Minimum current"
+							class="col-sm-6 mb-sm-0"
+							:help="
+								values.minCurrent && values.minCurrent < 6
+									? 'Only go below 6 A if you know what you\'re doing.'
+									: null
+							"
+						>
+							<PropertyField
+								id="vehicleParamMinCurrent"
+								v-model="values.minCurrent"
+								type="Float"
+								unit="A"
+								size="w-25 w-min-200"
+								class="me-2"
+							/>
+						</FormRow>
+						<FormRow
+							id="vehicleParamMaxCurrent"
+							label="Maximum current"
+							class="col-sm-6 mb-sm-0"
+							:help="
+								values.minCurrent &&
+								values.maxCurrent &&
+								values.maxCurrent < values.minCurrent
+									? 'Must be greater than minimum current.'
+									: null
+							"
+						>
+							<PropertyField
+								id="vehicleParamMaxCurrent"
+								v-model="values.maxCurrent"
+								type="Float"
+								unit="A"
+								size="w-25 w-min-200"
+								class="me-2"
+							/>
+						</FormRow>
+					</div>
 
-									<!-- todo: only show when multiple loadpoints exist -->
-									<FormRow
-										id="vehicleParamPriority"
-										label="Priority"
-										help="Changes the charging point priority when connecting this vehicle."
-									>
-										<PropertyField
-											id="vehicleParamPriority"
-											v-model="values.priority"
-											type="Choice"
-											size="w-100"
-											class="me-2"
-											:choice="priorityOptions"
-											required
-										/>
-									</FormRow>
+					<!-- todo: only show when multiple loadpoints exist -->
+					<FormRow
+						id="vehicleParamPriority"
+						label="Priority"
+						help="Changes the charging point priority when connecting this vehicle."
+					>
+						<PropertyField
+							id="vehicleParamPriority"
+							v-model="values.priority"
+							type="Choice"
+							size="w-100"
+							class="me-2"
+							:choice="priorityOptions"
+							required
+						/>
+					</FormRow>
 
-									<FormRow
-										id="vehicleParamIdentifiers"
-										label="RFID identifiers"
-										help="List of RFID strings to identify the vehicle. One per line. See the current identifier on the configuration overview."
-									>
-										<PropertyField
-											id="vehicleParamIdentifiers"
-											v-model="values.identifiers"
-											type="List"
-											property="identifiers"
-											size="w-100"
-											class="me-2"
-										/>
-									</FormRow>
-								</template>
-							</PropertyCollapsible>
+					<FormRow
+						id="vehicleParamIdentifiers"
+						label="RFID identifiers"
+						help="List of RFID strings to identify the vehicle. One per line. See the current identifier on the configuration overview."
+					>
+						<PropertyField
+							id="vehicleParamIdentifiers"
+							v-model="values.identifiers"
+							type="List"
+							property="identifiers"
+							size="w-100"
+							class="me-2"
+						/>
+					</FormRow>
+				</template>
+			</PropertyCollapsible>
 
-							<TestResult
-								v-if="templateName"
-								:success="testSuccess"
-								:failed="testFailed"
-								:unknown="testUnknown"
-								:running="testRunning"
-								:result="testResult"
-								:error="testError"
-								@test="testManually"
-							/>
+			<TestResult
+				v-if="templateName"
+				:success="testSuccess"
+				:failed="testFailed"
+				:unknown="testUnknown"
+				:running="testRunning"
+				:result="testResult"
+				:error="testError"
+				@test="testManually"
+			/>
 
-							<div v-if="templateName" class="my-4 d-flex justify-content-between">
-								<button
-									v-if="isDeletable"
-									type="button"
-									class="btn btn-link text-danger"
-									@click.prevent="remove"
-								>
-									{{ $t("config.vehicle.delete") }}
-								</button>
-								<button
-									v-else
-									type="button"
-									class="btn btn-link text-muted"
-									data-bs-dismiss="modal"
-								>
-									{{ $t("config.vehicle.cancel") }}
-								</button>
-								<button
-									type="submit"
-									class="btn btn-primary"
-									:disabled="testRunning || saving"
-									@click.prevent="isNew ? create() : update()"
-								>
-									<span
-										v-if="saving"
-										class="spinner-border spinner-border-sm"
-										role="status"
-										aria-hidden="true"
-									></span>
-									{{
-										testUnknown
-											? $t("config.vehicle.validateSave")
-											: $t("config.vehicle.save")
-									}}
-								</button>
-							</div>
-						</form>
-					</div>
-				</div>
+			<div v-if="templateName" class="my-4 d-flex justify-content-between">
+				<button
+					v-if="isDeletable"
+					type="button"
+					class="btn btn-link text-danger"
+					@click.prevent="remove"
+				>
+					{{ $t("config.vehicle.delete") }}
+				</button>
+				<button
+					v-else
+					type="button"
+					class="btn btn-link text-muted"
+					data-bs-dismiss="modal"
+				>
+					{{ $t("config.vehicle.cancel") }}
+				</button>
+				<button
+					type="submit"
+					class="btn btn-primary"
+					:disabled="testRunning || saving"
+					@click.prevent="isNew ? create() : update()"
+				>
+					<span
+						v-if="saving"
+						class="spinner-border spinner-border-sm"
+						role="status"
+						aria-hidden="true"
+					></span>
+					{{
+						testUnknown ? $t("config.vehicle.validateSave") : $t("config.vehicle.save")
+					}}
+				</button>
 			</div>
-		</div>
-	</Teleport>
+		</form>
+	</GenericModal>
 </template>
 
 <script>
@@ -264,6 +239,7 @@ import TestResult from "./TestResult.vue";
 import SelectGroup from "../SelectGroup.vue";
 import PropertyEntry from "./PropertyEntry.vue";
 import PropertyCollapsible from "./PropertyCollapsible.vue";
+import GenericModal from "../GenericModal.vue";
 import Markdown from "./Markdown.vue";
 import api from "../../api";
 import test from "./mixins/test";
@@ -282,6 +258,7 @@ export default {
 		FormRow,
 		PropertyField,
 		TestResult,
+		GenericModal,
 		SelectGroup,
 		PropertyCollapsible,
 		PropertyEntry,
@@ -385,14 +362,6 @@ export default {
 			deep: true,
 		},
 	},
-	mounted() {
-		this.$refs.modal.addEventListener("show.bs.modal", this.modalVisible);
-		this.$refs.modal.addEventListener("hide.bs.modal", this.modalInvisible);
-	},
-	unmounted() {
-		this.$refs.modal?.removeEventListener("show.bs.modal", this.modalVisible);
-		this.$refs.modal?.removeEventListener("hide.bs.modal", this.modalInvisible);
-	},
 	methods: {
 		reset() {
 			this.values = { ...initialValues };
@@ -410,7 +379,8 @@ export default {
 		},
 		async loadProducts() {
 			try {
-				this.products = (await api.get("config/products/vehicle")).data.result;
+				const opts = { params: { lang: this.$i18n?.locale } };
+				this.products = (await api.get("config/products/vehicle", opts)).data.result;
 			} catch (e) {
 				console.error(e);
 			}
@@ -450,7 +420,7 @@ export default {
 			try {
 				await api.post("config/devices/vehicle", this.apiData);
 				this.$emit("vehicle-changed");
-				this.modalInvisible();
+				this.closed();
 			} catch (e) {
 				console.error(e);
 				alert("create failed");
@@ -477,7 +447,7 @@ export default {
 			try {
 				await api.put(`config/devices/vehicle/${this.id}`, this.apiData);
 				this.$emit("vehicle-changed");
-				this.modalInvisible();
+				this.closed();
 			} catch (e) {
 				console.error(e);
 				alert("update failed");
@@ -488,16 +458,16 @@ export default {
 			try {
 				await api.delete(`config/devices/vehicle/${this.id}`);
 				this.$emit("vehicle-changed");
-				this.modalInvisible();
+				this.closed();
 			} catch (e) {
 				console.error(e);
 				alert("delete failed");
 			}
 		},
-		modalVisible() {
+		open() {
 			this.isModalVisible = true;
 		},
-		modalInvisible() {
+		closed() {
 			this.isModalVisible = false;
 		},
 		templateChanged() {
@@ -513,4 +483,3 @@ export default {
 	padding-right: 0;
 }
 </style>
-import type MarkdownVue from "./Markdown.vue";
diff --git a/assets/js/components/Config/YamlModal.vue b/assets/js/components/Config/YamlModal.vue
index 23d1ca4c..5da13370 100644
--- a/assets/js/components/Config/YamlModal.vue
+++ b/assets/js/components/Config/YamlModal.vue
@@ -138,8 +138,4 @@ export default {
 		margin: 0;
 	}
 }
-
-.btn-cancel {
-	margin-left: -0.75rem;
-}
 </style>
diff --git a/assets/js/components/GenericModal.vue b/assets/js/components/GenericModal.vue
index 58bc97d0..e008c294 100644
--- a/assets/js/components/GenericModal.vue
+++ b/assets/js/components/GenericModal.vue
@@ -3,8 +3,7 @@
 		<div
 			:id="id"
 			ref="modal"
-			class="modal fade text-dark"
-			:class="sizeClass"
+			:class="classes"
 			tabindex="-1"
 			role="dialog"
 			aria-hidden="true"
@@ -26,7 +25,7 @@
 							aria-label="Close"
 						></button>
 					</div>
-					<div class="modal-body">
+					<div ref="modalBody" class="modal-body">
 						<slot />
 					</div>
 				</div>
@@ -45,28 +44,69 @@ export default {
 		title: String,
 		dataTestid: String,
 		uncloseable: Boolean,
+		fade: String,
 		size: String,
 	},
-	emits: ["open", "closed"],
+	emits: ["open", "opened", "close", "closed"],
+	data: function () {
+		return {
+			isModalVisible: false,
+		};
+	},
 	computed: {
+		classes() {
+			return [
+				"modal",
+				"fade",
+				"text-dark",
+				{ show: this.isModalVisible },
+				this.sizeClass,
+				this.fadeClass,
+			];
+		},
 		sizeClass() {
 			return this.size ? `modal-${this.size}` : "";
 		},
+		fadeClass() {
+			if (this.fade) {
+				return `fade-${this.fade}`;
+			}
+			return "";
+		},
 	},
 	mounted() {
 		this.$refs.modal.addEventListener("show.bs.modal", this.handleShow);
+		this.$refs.modal.addEventListener("shown.bs.modal", this.handleShown);
+		this.$refs.modal.addEventListener("hide.bs.modal", this.handleHide);
 		this.$refs.modal.addEventListener("hidden.bs.modal", this.handleHidden);
 	},
 	unmounted() {
 		this.$refs.modal?.removeEventListener("show.bs.modal", this.handleShow);
+		this.$refs.modal?.removeEventListener("shown.bs.modal", this.handleShown);
+		this.$refs.modal?.removeEventListener("hide.bs.modal", this.handleHide);
 		this.$refs.modal?.removeEventListener("hidden.bs.modal", this.handleHidden);
 	},
 	methods: {
 		handleShow() {
 			this.$emit("open");
 		},
+		handleShown() {
+			this.$emit("opened");
+			// focus first input or select
+			this.$nextTick(() => {
+				const firstInput = this.$refs.modalBody.querySelector("input, select, button");
+				if (firstInput) {
+					firstInput.focus();
+				}
+			});
+			this.isModalVisible = true;
+		},
+		handleHide() {
+			this.$emit("close");
+		},
 		handleHidden() {
 			this.$emit("closed");
+			this.isModalVisible = false;
 		},
 		open() {
 			Modal.getOrCreateInstance(this.$refs.modal).show();
diff --git a/assets/js/components/Loadpoint.vue b/assets/js/components/Loadpoint.vue
index 90042216..6aecbaa0 100644
--- a/assets/js/components/Loadpoint.vue
+++ b/assets/js/components/Loadpoint.vue
@@ -184,7 +184,7 @@ export default {
 		phasesConfigured: Number,
 		phasesActive: Number,
 		chargerPhases1p3p: Boolean,
-		chargerPhysicalPhases: Number,
+		chargerSinglePhase: Boolean,
 		minCurrent: Number,
 		maxCurrent: Number,
 		chargeCurrent: Number,
diff --git a/assets/js/components/LoadpointSettingsModal.vue b/assets/js/components/LoadpointSettingsModal.vue
index 2fd21aed..94271c20 100644
--- a/assets/js/components/LoadpointSettingsModal.vue
+++ b/assets/js/components/LoadpointSettingsModal.vue
@@ -185,7 +185,7 @@ export default {
 		phasesConfigured: Number,
 		phasesActive: Number,
 		chargerPhases1p3p: Boolean,
-		chargerPhysicalPhases: Number,
+		chargerSinglePhase: Boolean,
 		batteryBoost: Boolean,
 		batteryBoostAvailable: Boolean,
 		mode: String,
@@ -215,8 +215,7 @@ export default {
 	},
 	computed: {
 		phasesOptions() {
-			if (this.chargerPhysicalPhases == 1) {
-				// known fixed phase configuration, no settings required
+			if (this.chargerSinglePhase) {
 				return [];
 			}
 			if (this.chargerPhases1p3p) {
diff --git a/assets/js/components/MaterialIcon/Add.vue b/assets/js/components/MaterialIcon/Add.vue
new file mode 100644
index 00000000..1e76afc8
--- /dev/null
+++ b/assets/js/components/MaterialIcon/Add.vue
@@ -0,0 +1,14 @@
+<template>
+	<svg :style="svgStyle" viewBox="0 0 24 24">
+		<path fill="currentColor" d="M11 13H5v-2h6V5h2v6h6v2h-6v6h-2z" />
+	</svg>
+</template>
+
+<script>
+import icon from "../../mixins/icon";
+
+export default {
+	name: "Add",
+	mixins: [icon],
+};
+</script>
diff --git a/assets/js/components/MaterialIcon/Loadpoint.vue b/assets/js/components/MaterialIcon/Loadpoint.vue
new file mode 100644
index 00000000..5defffd0
--- /dev/null
+++ b/assets/js/components/MaterialIcon/Loadpoint.vue
@@ -0,0 +1,17 @@
+<template>
+	<svg :style="svgStyle" viewBox="0 0 24 24">
+		<path
+			fill="currentColor"
+			d="M6 19h6V5H6Zm2-1l4-7h-2V6l-4 7.5h2Zm-4 3V5q0-.825.588-1.413Q5.175 3 6 3h6q.825 0 1.413.587Q14 4.175 14 5v7h1q.825 0 1.413.587Q17 13.175 17 14v4.5q0 .425.288.712q.287.288.712.288t.712-.288Q19 18.925 19 18.5v-7.2q-.225.125-.475.162q-.25.038-.525.038q-1.05 0-1.775-.725Q15.5 10.05 15.5 9q0-.8.438-1.438q.437-.637 1.162-.912L15 4.55l1.05-1.05l3.7 3.6q.375.375.562.875q.188.5.188 1.025v9.5q0 1.05-.725 1.775Q19.05 21 18 21q-1.05 0-1.775-.725q-.725-.725-.725-1.775v-5H14V21Zm8-2H6h6Zm6-9q.425 0 .712-.288Q19 9.425 19 9t-.288-.713Q18.425 8 18 8t-.712.287Q17 8.575 17 9t.288.712Q17.575 10 18 10Z"
+		/>
+	</svg>
+</template>
+
+<script>
+import icon from "../../mixins/icon";
+
+export default {
+	name: "Loadpoint",
+	mixins: [icon],
+};
+</script>
diff --git a/assets/js/components/Mode.vue b/assets/js/components/Mode.vue
index 0986782b..49e9394c 100644
--- a/assets/js/components/Mode.vue
+++ b/assets/js/components/Mode.vue
@@ -6,6 +6,7 @@
 			type="button"
 			class="btn flex-grow-1 flex-shrink-1"
 			:class="{ active: isActive(m) }"
+			tabindex="0"
 			@click="setTargetMode(m)"
 		>
 			{{ label(m) }}
@@ -73,6 +74,10 @@ export default {
 .btn:hover {
 	color: var(--evcc-gray);
 }
+.btn:focus {
+	outline: var(--bs-focus-ring-width) solid var(--bs-focus-ring-color);
+	outline-width: var(--bs-focus-ring-width);
+}
 .btn.active {
 	color: var(--evcc-background);
 	background: var(--evcc-default-text);
diff --git a/assets/js/components/OfflineIndicator.vue b/assets/js/components/OfflineIndicator.vue
index 96316047..0d295275 100644
--- a/assets/js/components/OfflineIndicator.vue
+++ b/assets/js/components/OfflineIndicator.vue
@@ -12,6 +12,7 @@
 					class="btn btn-secondary me-2 btn-sm d-flex align-items-center"
 					type="button"
 					disabled
+					tabindex="0"
 				>
 					<span
 						class="spinner-border spinner-border-sm m-1 me-2"
@@ -30,6 +31,7 @@
 				<button
 					class="btn btn-secondary me-2 btn-sm d-flex align-items-center"
 					type="button"
+					tabindex="0"
 					@click="restart"
 				>
 					<Sync class="restart me-2" />
@@ -62,6 +64,7 @@
 					type="button"
 					class="btn-close mt-1"
 					aria-label="Close"
+					tabindex="0"
 					@click="dismissed = true"
 				></button>
 			</div>
diff --git a/assets/js/components/SelectGroup.vue b/assets/js/components/SelectGroup.vue
index a746dbb7..7af0c563 100644
--- a/assets/js/components/SelectGroup.vue
+++ b/assets/js/components/SelectGroup.vue
@@ -8,6 +8,8 @@
 			class="btn btn-sm flex-grow-1 flex-shrink-1"
 			:class="{ active: option.value === modelValue, 'btn--equal': equalWidth }"
 			:disabled="option.disabled"
+			:data-testid="`${id}-${option.value}`"
+			tabindex="0"
 			@click="$emit('update:modelValue', option.value)"
 		>
 			{{ option.name }}
@@ -56,6 +58,10 @@ export default {
 .btn:hover {
 	color: var(--evcc-gray);
 }
+.btn:focus {
+	outline: var(--bs-focus-ring-width) solid var(--bs-focus-ring-color);
+	outline-width: var(--bs-focus-ring-width);
+}
 .btn.active {
 	color: var(--evcc-background);
 	background: var(--evcc-default-text);
diff --git a/assets/js/components/Site.vue b/assets/js/components/Site.vue
index a31777fb..217ea270 100644
--- a/assets/js/components/Site.vue
+++ b/assets/js/components/Site.vue
@@ -3,7 +3,9 @@
 		<div class="container px-4 top-area">
 			<div class="d-flex justify-content-between align-items-center my-3 my-md-4">
 				<h1 class="d-block my-0">
-					{{ siteTitle || "evcc" }}
+					<span v-if="!isInitialSetup">
+						{{ siteTitle || "evcc" }}
+					</span>
 				</h1>
 				<div class="d-flex">
 					<Notifications
@@ -14,12 +16,31 @@
 					<TopNavigation v-bind="topNavigation" />
 				</div>
 			</div>
-			<Energyflow v-bind="energyflow" />
+			<Energyflow v-if="loadpoints.length > 0" v-bind="energyflow" />
 		</div>
 		<div class="d-flex flex-column justify-content-between content-area">
 			<div v-if="fatal" class="flex-grow-1 align-items-center d-flex justify-content-center">
 				<h1 class="mb-5 text-gray fs-4">{{ $t("startupError.title") }}</h1>
 			</div>
+			<div
+				v-else-if="isInitialSetup"
+				class="flex-grow-1 d-flex align-items-center justify-content-center p-3"
+			>
+				<div
+					class="welcome d-flex align-items-center flex-column justify-content-center text-center"
+				>
+					<h1 class="mb-0 fs-4 d-flex align-items-center gap-2">
+						{{ $t("main.welcome") }}
+					</h1>
+					<WelcomeIcons class="welcome-icons" />
+					<router-link
+						class="btn btn-lg btn-outline-primary configure-button"
+						to="/config"
+					>
+						{{ $t("main.startConfiguration") }}
+					</router-link>
+				</div>
+			</div>
 			<Loadpoints
 				v-else
 				class="mt-1 mt-sm-2 flex-grow-1"
@@ -48,6 +69,7 @@ import Loadpoints from "./Loadpoints.vue";
 import Footer from "./Footer.vue";
 import formatter from "../mixins/formatter";
 import collector from "../mixins/collector";
+import WelcomeIcons from "./WelcomeIcons.vue";
 
 export default {
 	name: "Site",
@@ -57,6 +79,7 @@ export default {
 		Footer,
 		Notifications,
 		TopNavigation,
+		WelcomeIcons,
 	},
 	mixins: [formatter, collector],
 	props: {
@@ -143,6 +166,9 @@ export default {
 			// work in progess
 			return false;
 		},
+		isInitialSetup: function () {
+			return this.loadpoints.length === 0;
+		},
 		footer: function () {
 			return {
 				version: {
@@ -177,4 +203,30 @@ export default {
 }
 .fatal {
 }
+
+.configure-button:not(:active):not(:hover),
+.welcome-icons {
+	animation: colorTransition 10s infinite alternate;
+	animation-timing-function: ease-in-out;
+}
+
+@keyframes colorTransition {
+	0% {
+		color: var(--evcc-accent1);
+		border-color: var(--evcc-accent1);
+	}
+	50% {
+		color: var(--evcc-accent2);
+		border-color: var(--evcc-accent2);
+	}
+	100% {
+		color: var(--evcc-accent3);
+		border-color: var(--evcc-accent3);
+	}
+}
+.welcome {
+	background-color: var(--evcc-box);
+	padding: 4rem;
+	border-radius: 2rem;
+}
 </style>
diff --git a/assets/js/components/WelcomeIcons.vue b/assets/js/components/WelcomeIcons.vue
new file mode 100644
index 00000000..0e561d96
--- /dev/null
+++ b/assets/js/components/WelcomeIcons.vue
@@ -0,0 +1,121 @@
+<template>
+	<div class="d-flex justify-content-center gap-4 my-5 color-transition">
+		<transition name="fade" mode="out-in">
+			<component :is="leftIcon" :key="leftIconIndex" class="animated-icon"></component>
+		</transition>
+		<transition name="fade" mode="out-in">
+			<component :is="centerIcon" :key="centerIconIndex" class="animated-icon"></component>
+		</transition>
+		<transition name="fade" mode="out-in">
+			<component :is="rightIcon" :key="rightIconIndex" class="animated-icon"></component>
+		</transition>
+	</div>
+</template>
+
+<script>
+import "@h2d2/shopicons/es/regular/batterythreequarters";
+import "@h2d2/shopicons/es/regular/cablecharge";
+import "@h2d2/shopicons/es/regular/car3";
+import "@h2d2/shopicons/es/regular/car1";
+import "@h2d2/shopicons/es/regular/eco1";
+import "@h2d2/shopicons/es/regular/heart";
+import "@h2d2/shopicons/es/regular/home";
+import "@h2d2/shopicons/es/regular/lightning";
+import "@h2d2/shopicons/es/regular/moonstars";
+import "@h2d2/shopicons/es/regular/powersupply";
+import "@h2d2/shopicons/es/regular/receivepayment";
+import "@h2d2/shopicons/es/regular/sun";
+import "@h2d2/shopicons/es/regular/clock";
+import BatteryBoost from "./MaterialIcon/BatteryBoost.vue";
+import SunUp from "./MaterialIcon/SunUp.vue";
+import DynamicPrice from "./MaterialIcon/DynamicPrice.vue";
+import { markRaw } from "vue";
+
+export default {
+	data() {
+		return {
+			leftIconIndex: 2,
+			centerIconIndex: 6,
+			rightIconIndex: 10,
+			updatePosition: 0,
+			icons: [
+				"shopicon-regular-batterythreequarters",
+				"shopicon-regular-cablecharge",
+				"shopicon-regular-car3",
+				"shopicon-regular-eco1",
+				"shopicon-regular-heart",
+				"shopicon-regular-home",
+				"shopicon-regular-lightning",
+				"shopicon-regular-moonstars",
+				"shopicon-regular-powersupply",
+				"shopicon-regular-receivepayment",
+				"shopicon-regular-sun",
+				"shopicon-regular-clock",
+				"shopicon-regular-car1",
+				markRaw(BatteryBoost),
+				markRaw(SunUp),
+				markRaw(DynamicPrice),
+			],
+			interval: null,
+		};
+	},
+	computed: {
+		leftIcon() {
+			return this.icons[this.leftIconIndex];
+		},
+		centerIcon() {
+			return this.icons[this.centerIconIndex];
+		},
+		rightIcon() {
+			return this.icons[this.rightIconIndex];
+		},
+	},
+	mounted() {
+		this.interval = setInterval(this.rotateIcons, 3000);
+	},
+	unmounted() {
+		clearInterval(this.interval);
+	},
+	methods: {
+		getRandomIcon(excludeIndices) {
+			const availableIndices = Array.from(Array(this.icons.length).keys()).filter(
+				(i) => !excludeIndices.includes(i)
+			);
+			const randomIndex = Math.floor(Math.random() * availableIndices.length);
+			return availableIndices[randomIndex];
+		},
+		rotateIcons() {
+			const excludeIndices = [this.leftIconIndex, this.centerIconIndex, this.rightIconIndex];
+			switch (this.updatePosition) {
+				case 0:
+					this.leftIconIndex = this.getRandomIcon(excludeIndices);
+					break;
+				case 1:
+					this.centerIconIndex = this.getRandomIcon(excludeIndices);
+					break;
+				case 2:
+					this.rightIconIndex = this.getRandomIcon(excludeIndices);
+					break;
+			}
+			this.updatePosition = (this.updatePosition + Math.ceil(Math.random() * 2)) % 3;
+		},
+	},
+};
+</script>
+
+<style scoped>
+.animated-icon {
+	width: 3.5rem !important;
+	height: 3.5rem !important;
+}
+
+.fade-enter-active,
+.fade-leave-active {
+	transition: opacity 0.5s ease;
+}
+
+.fade-enter-from,
+.fade-leave-to {
+	opacity: 0;
+}
+</style>
diff --git a/assets/js/mixins/formatter.js b/assets/js/mixins/formatter.js
index a86ff9cb..1d487e07 100644
--- a/assets/js/mixins/formatter.js
+++ b/assets/js/mixins/formatter.js
@@ -253,13 +253,13 @@ export default {
         day: "numeric",
       }).format(date);
     },
-    fmtSecondUnit: function (seconds) {
+    fmtDurationUnit: function (value, unit = "second") {
       return new Intl.NumberFormat(this.$i18n?.locale, {
         style: "unit",
-        unit: "second",
+        unit,
         unitDisplay: "long",
       })
-        .formatToParts(seconds)
+        .formatToParts(value)
         .find((part) => part.type === "unit").value;
     },
     fmtMoney: function (amout = 0, currency = "EUR", decimals = true, withSymbol = false) {
diff --git a/assets/js/utils/deepClone.js b/assets/js/utils/deepClone.js
new file mode 100644
index 00000000..acf99cbc
--- /dev/null
+++ b/assets/js/utils/deepClone.js
@@ -0,0 +1,3 @@
+export default function (obj) {
+  return JSON.parse(JSON.stringify(obj));
+}
diff --git a/assets/js/views/Config.vue b/assets/js/views/Config.vue
index 0067c650..cbc88443 100644
--- a/assets/js/views/Config.vue
+++ b/assets/js/views/Config.vue
@@ -18,7 +18,6 @@
 					<p class="mb-1"><strong>Missing features</strong></p>
 					<ul>
 						<li>aux meters</li>
-						<li>loadpoints and chargers</li>
 						<li>custom/plugin meters and vehicles</li>
 						<li>migration for vehicles, chargers, meters, loadpoints</li>
 						<li>remove mixed mode (evcc.yaml + db) for meters and vehicles</li>
@@ -36,31 +35,85 @@
 				<GeneralConfig @site-changed="siteChanged" />
 
 				<div v-if="$hiddenFeatures()">
+					<h2 class="my-4">{{ $t("config.section.loadpoints") }} 🧪</h2>
+					<ul class="p-0 config-list">
+						<DeviceCard
+							v-for="loadpoint in loadpoints"
+							:key="loadpoint.name"
+							:name="loadpoint.title"
+							:editable="!!loadpoint.id"
+							data-testid="loadpoint"
+							@edit="editLoadpoint(loadpoint.id)"
+						>
+							<template #tags>
+								<DeviceTags :tags="loadpointTags(loadpoint)" />
+							</template>
+							<template #icon>
+								<VehicleIcon
+									v-if="chargerIcon(loadpoint.charger)"
+									:name="chargerIcon(loadpoint.charger)"
+								/>
+								<LoadpointIcon v-else />
+							</template>
+						</DeviceCard>
+
+						<NewDeviceButton
+							data-testid="add-loadpoint"
+							:title="$t('config.main.addLoadpoint')"
+							@click="newLoadpoint"
+						/>
+					</ul>
+
+					<h2 class="my-4">{{ $t("config.section.vehicles") }} 🧪</h2>
+					<ul class="p-0 config-list">
+						<DeviceCard
+							v-for="vehicle in vehicles"
+							:key="vehicle.name"
+							:name="vehicle.config?.title || vehicle.name"
+							:editable="vehicle.id >= 0"
+							:error="deviceError('vehicle', vehicle.name)"
+							data-testid="vehicle"
+							@edit="editVehicle(vehicle.id)"
+						>
+							<template #icon>
+								<VehicleIcon :name="vehicle.config?.icon" />
+							</template>
+							<template #tags>
+								<DeviceTags :tags="deviceTags('vehicle', vehicle.name)" />
+							</template>
+						</DeviceCard>
+						<NewDeviceButton
+							data-testid="add-vehicle"
+							:title="$t('config.main.addVehicle')"
+							@click="newVehicle"
+						/>
+					</ul>
+
 					<h2 class="my-4 mt-5">{{ $t("config.section.grid") }} 🧪</h2>
 					<ul class="p-0 config-list">
 						<DeviceCard
+							v-if="gridMeter?.id"
 							:name="$t('config.grid.title')"
-							:editable="!gridMeter || !!gridMeter.id"
-							:error="deviceError('meter', gridMeter?.name)"
+							editable
+							:error="deviceError('meter', gridMeter.name)"
 							data-testid="grid"
-							@edit="
-								gridMeter?.id ? editMeter(gridMeter.id, 'grid') : addMeter('grid')
-							"
+							@edit="editMeter(gridMeter.id, 'grid')"
 						>
 							<template #icon>
 								<shopicon-regular-powersupply></shopicon-regular-powersupply>
 							</template>
 							<template #tags>
-								<DeviceTags
-									:tags="
-										gridMeter
-											? deviceTags('meter', gridMeter.name)
-											: { configured: { value: false } }
-									"
-								/>
+								<DeviceTags :tags="deviceTags('meter', gridMeter.name)" />
 							</template>
 						</DeviceCard>
+						<NewDeviceButton
+							v-else
+							:title="$t('config.main.addGrid')"
+							data-testid="add-grid"
+							@click="newMeter('grid')"
+						/>
 						<DeviceCard
+							v-if="tariffTags"
 							:name="$t('config.tariffs.title')"
 							editable
 							:error="fatalClass === 'tariff'"
@@ -74,6 +127,12 @@
 								<DeviceTags :tags="tariffTags" />
 							</template>
 						</DeviceCard>
+						<NewDeviceButton
+							v-else
+							:title="$t('config.main.addTariffs')"
+							data-testid="add-tariffs"
+							@click="openModal('tariffsModal')"
+						/>
 					</ul>
 					<h2 class="my-4 mt-5">{{ $t("config.section.meter") }} 🧪</h2>
 					<ul class="p-0 config-list">
@@ -109,163 +168,116 @@
 								<DeviceTags :tags="deviceTags('meter', meter.name)" />
 							</template>
 						</DeviceCard>
-						<AddDeviceButton :title="$t('config.main.addPvBattery')" @add="addMeter" />
+						<NewDeviceButton
+							:title="$t('config.main.addPvBattery')"
+							@click="newMeter"
+						/>
 					</ul>
 
-					<h2 class="my-4 wip">{{ $t("config.section.loadpoints") }} 🧪</h2>
+					<h2 class="my-4 mt-5">{{ $t("config.section.integrations") }} 🧪</h2>
 
-					<ul class="p-0 config-list wip">
+					<ul class="p-0 config-list">
 						<DeviceCard
-							name="Fake Carport"
+							:name="$t('config.mqtt.title')"
 							editable
-							:error="deviceError('charger', 'fake-charger')"
-							data-testid="chargepoint-1"
-							@edit="todo"
+							:error="fatalClass === 'mqtt'"
+							data-testid="mqtt"
+							@edit="openModal('mqttModal')"
 						>
-							<template #icon>
-								<shopicon-regular-cablecharge></shopicon-regular-cablecharge>
+							<template #icon><MqttIcon /></template>
+							<template #tags>
+								<DeviceTags :tags="mqttTags" />
 							</template>
+						</DeviceCard>
+						<DeviceCard
+							:name="$t('config.messaging.title')"
+							editable
+							:error="fatalClass === 'messenger'"
+							data-testid="messaging"
+							@edit="openModal('messagingModal')"
+						>
+							<template #icon><NotificationIcon /></template>
 							<template #tags>
-								<DeviceTags :tags="{ power: 0 }" />
+								<DeviceTags :tags="messagingTags" />
+							</template>
+						</DeviceCard>
+						<DeviceCard
+							:name="$t('config.influx.title')"
+							editable
+							:error="fatalClass === 'influx'"
+							data-testid="influx"
+							@edit="openModal('influxModal')"
+						>
+							<template #icon><InfluxIcon /></template>
+							<template #tags>
+								<DeviceTags :tags="influxTags" />
+							</template>
+						</DeviceCard>
+						<DeviceCard
+							:name="`${$t('config.eebus.title')} 🧪`"
+							editable
+							:error="fatalClass === 'eebus'"
+							data-testid="eebus"
+							@edit="openModal('eebusModal')"
+						>
+							<template #icon><EebusIcon /></template>
+							<template #tags>
+								<DeviceTags :tags="eebusTags" />
 							</template>
 						</DeviceCard>
-						<AddDeviceButton
-							data-testid="add-loadpoint"
-							:title="$t('config.main.addLoadpoint')"
-							@click="todo"
-						/>
-					</ul>
-
-					<h2 class="my-4">{{ $t("config.section.vehicles") }} 🧪</h2>
-					<div>
-						<ul class="p-0 config-list">
-							<DeviceCard
-								v-for="vehicle in vehicles"
-								:key="vehicle.name"
-								:name="vehicle.config?.title || vehicle.name"
-								:editable="vehicle.id >= 0"
-								:error="deviceError('vehicle', vehicle.name)"
-								data-testid="vehicle"
-								@edit="editVehicle(vehicle.id)"
-							>
-								<template #icon>
-									<VehicleIcon :name="vehicle.config?.icon" />
-								</template>
-								<template #tags>
-									<DeviceTags :tags="deviceTags('vehicle', vehicle.name)" />
-								</template>
-							</DeviceCard>
-							<AddDeviceButton
-								data-testid="add-vehicle"
-								:title="$t('config.main.addVehicle')"
-								@click="addVehicle"
-							/>
-						</ul>
-
-						<h2 class="my-4 mt-5">{{ $t("config.section.integrations") }} 🧪</h2>
 
-						<ul class="p-0 config-list">
-							<DeviceCard
-								:name="$t('config.mqtt.title')"
-								editable
-								:error="fatalClass === 'mqtt'"
-								data-testid="mqtt"
-								@edit="openModal('mqttModal')"
-							>
-								<template #icon><MqttIcon /></template>
-								<template #tags>
-									<DeviceTags :tags="mqttTags" />
-								</template>
-							</DeviceCard>
-							<DeviceCard
-								:name="$t('config.messaging.title')"
-								editable
-								:error="fatalClass === 'messenger'"
-								data-testid="messaging"
-								@edit="openModal('messagingModal')"
-							>
-								<template #icon><NotificationIcon /></template>
-								<template #tags>
-									<DeviceTags :tags="messagingTags" />
-								</template>
-							</DeviceCard>
-							<DeviceCard
-								:name="$t('config.influx.title')"
-								editable
-								:error="fatalClass === 'influx'"
-								data-testid="influx"
-								@edit="openModal('influxModal')"
-							>
-								<template #icon><InfluxIcon /></template>
-								<template #tags>
-									<DeviceTags :tags="influxTags" />
-								</template>
-							</DeviceCard>
-							<DeviceCard
-								:name="`${$t('config.eebus.title')} 🧪`"
-								editable
-								:error="fatalClass === 'eebus'"
-								data-testid="eebus"
-								@edit="openModal('eebusModal')"
-							>
-								<template #icon><EebusIcon /></template>
-								<template #tags>
-									<DeviceTags :tags="eebusTags" />
-								</template>
-							</DeviceCard>
-							<DeviceCard
-								:name="`${$t('config.circuits.title')} 🧪`"
-								editable
-								:error="fatalClass === 'circuit'"
-								data-testid="circuits"
-								@edit="openModal('circuitsModal')"
-							>
-								<template #icon><CircuitsIcon /></template>
-								<template #tags>
-									<DeviceTags
-										v-if="circuits.length == 0"
-										:tags="{ configured: { value: false } }"
-									/>
-									<template
-										v-for="(circuit, idx) in circuits"
-										v-else
-										:key="circuit.name"
-									>
-										<hr v-if="idx > 0" />
-										<p class="my-2 fw-bold">
-											{{ circuit.config?.title }}
-											<code>({{ circuit.name }})</code>
-										</p>
-										<DeviceTags :tags="circuitTags(circuit)" />
-									</template>
-								</template>
-							</DeviceCard>
-							<DeviceCard
-								:name="$t('config.modbusproxy.title')"
-								editable
-								:error="fatalClass === 'modbusproxy'"
-								data-testid="modbusproxy"
-								@edit="openModal('modbusProxyModal')"
-							>
-								<template #icon><ModbusProxyIcon /></template>
-								<template #tags>
-									<DeviceTags :tags="modbusproxyTags" />
-								</template>
-							</DeviceCard>
-							<DeviceCard
-								:name="$t('config.hems.title')"
-								editable
-								:error="fatalClass === 'hems'"
-								data-testid="hems"
-								@edit="openModal('hemsModal')"
-							>
-								<template #icon><HemsIcon /></template>
-								<template #tags>
-									<DeviceTags :tags="hemsTags" />
+						<DeviceCard
+							:name="`${$t('config.circuits.title')} 🧪`"
+							editable
+							:error="fatalClass === 'circuit'"
+							data-testid="circuits"
+							@edit="openModal('circuitsModal')"
+						>
+							<template #icon><CircuitsIcon /></template>
+							<template #tags>
+								<DeviceTags
+									v-if="circuits.length == 0"
+									:tags="{ configured: { value: false } }"
+								/>
+								<template
+									v-for="(circuit, idx) in circuits"
+									v-else
+									:key="circuit.name"
+								>
+									<hr v-if="idx > 0" />
+									<p class="my-2 fw-bold">
+										{{ circuit.config?.title }}
+										<code>({{ circuit.name }})</code>
+									</p>
+									<DeviceTags :tags="circuitTags(circuit)" />
 								</template>
-							</DeviceCard>
-						</ul>
-					</div>
+							</template>
+						</DeviceCard>
+						<DeviceCard
+							:name="$t('config.modbusproxy.title')"
+							editable
+							:error="fatalClass === 'modbusproxy'"
+							data-testid="modbusproxy"
+							@edit="openModal('modbusProxyModal')"
+						>
+							<template #icon><ModbusProxyIcon /></template>
+							<template #tags>
+								<DeviceTags :tags="yamlTags('modbusproxy')" />
+							</template>
+						</DeviceCard>
+						<DeviceCard
+							:name="$t('config.hems.title')"
+							editable
+							:error="fatalClass === 'hems'"
+							data-testid="hems"
+							@edit="openModal('hemsModal')"
+						>
+							<template #icon><HemsIcon /></template>
+							<template #tags>
+								<DeviceTags :tags="hemsTags" />
+							</template>
+						</DeviceCard>
+					</ul>
 				</div>
 
 				<hr class="my-5" />
@@ -280,14 +292,41 @@
 					</button>
 				</div>
 
+				<LoadpointModal
+					:id="selectedLoadpointId"
+					ref="loadpointModal"
+					:vehicleOptions="vehicleOptions"
+					:loadpointCount="loadpoints.length"
+					:chargers="chargers"
+					:chargerValues="deviceValues.charger"
+					:meters="meters"
+					:circuits="circuits"
+					:fade="loadpointSubModalOpen ? 'left' : ''"
+					@updated="loadpointChanged"
+					@open-charger-modal="editLoadpointCharger"
+					@open-meter-modal="editLoadpointMeter"
+					@opened="loadpointSubModalOpen = false"
+				/>
 				<VehicleModal :id="selectedVehicleId" @vehicle-changed="vehicleChanged" />
 				<MeterModal
 					:id="selectedMeterId"
 					:name="selectedMeterName"
 					:type="selectedMeterType"
-					@added="addMeterToSite"
+					:fade="loadpointSubModalOpen ? 'right' : ''"
+					@added="addMeter"
 					@updated="meterChanged"
-					@removed="removeMeterFromSite"
+					@removed="removeMeter"
+					@close="meterModalClosed"
+				/>
+				<ChargerModal
+					:id="selectedChargerId"
+					:name="selectedChargerName"
+					:fade="loadpointSubModalOpen ? 'right' : ''"
+					:isSponsor="isSponsor"
+					@added="addCharger"
+					@updated="chargerChanged"
+					@removed="removeCharger"
+					@close="chargerModalClosed"
 				/>
 				<InfluxModal @changed="loadDirty" />
 				<MqttModal @changed="loadDirty" />
@@ -306,18 +345,17 @@
 </template>
 
 <script>
-import TopHeader from "../components/TopHeader.vue";
 import "@h2d2/shopicons/es/regular/sun";
 import "@h2d2/shopicons/es/regular/batterythreequarters";
 import "@h2d2/shopicons/es/regular/powersupply";
 import "@h2d2/shopicons/es/regular/receivepayment";
-import "@h2d2/shopicons/es/regular/cablecharge";
-import AddDeviceButton from "../components/Config/AddDeviceButton.vue";
+import NewDeviceButton from "../components/Config/NewDeviceButton.vue";
 import api from "../api";
+import ChargerModal from "../components/Config/ChargerModal.vue";
 import CircuitsIcon from "../components/MaterialIcon/Circuits.vue";
 import CircuitsModal from "../components/Config/CircuitsModal.vue";
-import ControlModal from "../components/Config/ControlModal.vue";
 import collector from "../mixins/collector";
+import ControlModal from "../components/Config/ControlModal.vue";
 import DeviceCard from "../components/Config/DeviceCard.vue";
 import DeviceTags from "../components/Config/DeviceTags.vue";
 import EebusIcon from "../components/MaterialIcon/Eebus.vue";
@@ -328,6 +366,8 @@ import HemsIcon from "../components/MaterialIcon/Hems.vue";
 import HemsModal from "../components/Config/HemsModal.vue";
 import InfluxIcon from "../components/MaterialIcon/Influx.vue";
 import InfluxModal from "../components/Config/InfluxModal.vue";
+import LoadpointModal from "../components/Config/LoadpointModal.vue";
+import LoadpointIcon from "../components/MaterialIcon/Loadpoint.vue";
 import MessagingModal from "../components/Config/MessagingModal.vue";
 import MeterModal from "../components/Config/MeterModal.vue";
 import Modal from "bootstrap/js/dist/modal";
@@ -338,16 +378,18 @@ import MqttModal from "../components/Config/MqttModal.vue";
 import NetworkModal from "../components/Config/NetworkModal.vue";
 import NotificationIcon from "../components/MaterialIcon/Notification.vue";
 import restart, { performRestart } from "../restart";
-import store from "../store";
 import SponsorModal from "../components/Config/SponsorModal.vue";
+import store from "../store";
 import TariffsModal from "../components/Config/TariffsModal.vue";
+import TopHeader from "../components/TopHeader.vue";
 import VehicleIcon from "../components/VehicleIcon";
 import VehicleModal from "../components/Config/VehicleModal.vue";
 
 export default {
 	name: "Config",
 	components: {
-		AddDeviceButton,
+		NewDeviceButton,
+		ChargerModal,
 		CircuitsIcon,
 		CircuitsModal,
 		ControlModal,
@@ -362,6 +404,8 @@ export default {
 		InfluxModal,
 		MessagingModal,
 		MeterModal,
+		LoadpointModal,
+		LoadpointIcon,
 		ModbusProxyIcon,
 		ModbusProxyModal,
 		MqttIcon,
@@ -383,11 +427,16 @@ export default {
 		return {
 			vehicles: [],
 			meters: [],
+			loadpoints: [],
+			chargers: [],
 			circuits: [],
 			selectedVehicleId: undefined,
 			selectedMeterId: undefined,
 			selectedMeterType: undefined,
-			site: { grid: "", pv: [], battery: [] },
+			selectedChargerId: undefined,
+			selectedLoadpointId: undefined,
+			loadpointSubModalOpen: false,
+			site: { grid: "", pv: [], battery: [], title: "" },
 			deviceValueTimeout: undefined,
 			deviceValues: {},
 		};
@@ -414,8 +463,14 @@ export default {
 		selectedMeterName() {
 			return this.getMeterById(this.selectedMeterId)?.name;
 		},
+		selectedChargerName() {
+			return this.getChargerById(this.selectedChargerId)?.name;
+		},
 		tariffTags() {
 			const { currency, tariffGrid, tariffFeedIn, tariffCo2 } = store.state;
+			if (tariffGrid === undefined && tariffFeedIn === undefined && tariffCo2 === undefined) {
+				return null;
+			}
 			const tags = {};
 			if (currency) {
 				tags.currency = { value: currency };
@@ -447,6 +502,9 @@ export default {
 			if (org) result.org = { value: org };
 			return result;
 		},
+		vehicleOptions() {
+			return this.vehicles.map((v) => ({ key: v.name, name: v.config?.title || v.name }));
+		},
 		hemsTags() {
 			const result = { configured: { value: false } };
 			const { type } = store.state?.hems || {};
@@ -456,6 +514,10 @@ export default {
 			}
 			return result;
 		},
+		isSponsor() {
+			const { name } = store.state?.sponsor || {};
+			return !!name;
+		},
 		eebusTags() {
 			return { configured: { value: store.state?.eebus || false } };
 		},
@@ -488,6 +550,8 @@ export default {
 			await this.loadVehicles();
 			await this.loadMeters();
 			await this.loadSite();
+			await this.loadChargers();
+			await this.loadLoadpoints();
 			await this.loadCircuits();
 			await this.loadDirty();
 			await this.updateValues();
@@ -502,6 +566,10 @@ export default {
 			const response = await api.get("/config/devices/vehicle");
 			this.vehicles = response.data?.result || [];
 		},
+		async loadChargers() {
+			const response = await api.get("/config/devices/charger");
+			this.chargers = response.data?.result || [];
+		},
 		async loadMeters() {
 			const response = await api.get("/config/devices/meter");
 			this.meters = response.data?.result || [];
@@ -518,6 +586,10 @@ export default {
 				this.site = response.data?.result;
 			}
 		},
+		async loadLoadpoints() {
+			const response = await api.get("/config/loadpoints");
+			this.loadpoints = response.data?.result || [];
+		},
 		getMetersByNames(names) {
 			if (!names || !this.meters) {
 				return [];
@@ -530,35 +602,95 @@ export default {
 			}
 			return this.meters.find((m) => m.id === id);
 		},
+		getChargerById(id) {
+			if (!id || !this.chargers) {
+				return undefined;
+			}
+			return this.chargers.find((c) => c.id === id);
+		},
 		vehicleModal() {
 			return Modal.getOrCreateInstance(document.getElementById("vehicleModal"));
 		},
 		meterModal() {
 			return Modal.getOrCreateInstance(document.getElementById("meterModal"));
 		},
+		loadpointModal() {
+			return Modal.getOrCreateInstance(document.getElementById("loadpointModal"));
+		},
+		chargerModal() {
+			return Modal.getOrCreateInstance(document.getElementById("chargerModal"));
+		},
+		editLoadpointCharger(name) {
+			this.loadpointSubModalOpen = true;
+			const charger = this.chargers.find((c) => c.name === name);
+			if (charger && charger.id === undefined) {
+				alert(
+					"yaml configured chargers can not be edited. Remove charger from yaml first."
+				);
+				return;
+			}
+			this.loadpointModal().hide();
+			this.$nextTick(() => this.editCharger(charger?.id));
+		},
+		editLoadpointMeter(name) {
+			this.loadpointSubModalOpen = true;
+			const meter = this.meters.find((m) => m.name === name);
+			if (meter && meter.id === undefined) {
+				alert("yaml configured meters can not be edited. Remove meter from yaml first.");
+				return;
+			}
+			this.loadpointModal().hide();
+			this.$nextTick(() => this.editMeter(meter?.id, "charge"));
+		},
 		editMeter(id, type) {
 			this.selectedMeterId = id;
 			this.selectedMeterType = type;
 			this.$nextTick(() => this.meterModal().show());
 		},
-		addMeter(type) {
+		newMeter(type) {
 			this.selectedMeterId = undefined;
 			this.selectedMeterType = type;
 			this.$nextTick(() => this.meterModal().show());
 		},
+		editCharger(id) {
+			this.selectedChargerId = id;
+			this.$nextTick(() => this.chargerModal().show());
+		},
+		newCharger() {
+			this.selectedChargerId = undefined;
+			this.$nextTick(() => this.chargerModal().show());
+		},
 		async meterChanged() {
-			this.selectedMeterId = undefined;
-			this.selectedMeterType = undefined;
 			await this.loadMeters();
 			this.meterModal().hide();
 			await this.loadDirty();
 			await this.updateValues();
 		},
+		async chargerChanged() {
+			await this.loadChargers();
+			this.chargerModal().hide();
+			await this.loadDirty();
+			await this.updateValues();
+		},
+		editLoadpoint(id) {
+			this.selectedLoadpointId = id;
+			this.$nextTick(() => this.loadpointModal().show());
+		},
+		newLoadpoint() {
+			this.selectedLoadpointId = undefined;
+			this.$nextTick(() => this.loadpointModal().show());
+		},
+		async loadpointChanged() {
+			this.selectedLoadpointId = undefined;
+			await this.loadLoadpoints();
+			this.loadpointModal().hide();
+			this.loadDirty();
+		},
 		editVehicle(id) {
 			this.selectedVehicleId = id;
 			this.$nextTick(() => this.vehicleModal().show());
 		},
-		addVehicle() {
+		newVehicle() {
 			this.selectedVehicleId = undefined;
 			this.$nextTick(() => this.vehicleModal().show());
 		},
@@ -574,24 +706,52 @@ export default {
 		yamlChanged() {
 			this.loadDirty();
 		},
-		addMeterToSite(type, name) {
-			if (type === "grid") {
+		addMeter(type, name) {
+			if (type === "charge") {
+				// update loadpoint
+				this.$refs.loadpointModal?.setMeter(name);
+			} else if (type === "grid") {
+				// update site grid
 				this.site.grid = name;
+				this.saveSite(type);
 			} else {
+				// update site pv, battery, aux
 				if (!this.site[type]) {
 					this.site[type] = [];
 				}
 				this.site[type].push(name);
+				this.saveSite(type);
 			}
-			this.saveSite(type);
 		},
-		removeMeterFromSite(type, name) {
-			if (type === "grid") {
+		removeMeter(type, name) {
+			if (type === "charge") {
+				// update loadpoint
+				this.$refs.loadpointModal?.setMeter(undefined);
+			} else if (type === "grid") {
+				// update site grid
 				this.site.grid = "";
-			} else if (this.site[type]) {
+				this.saveSite(type);
+			} else {
+				// update site pv, battery, aux
 				this.site[type] = this.site[type].filter((i) => i !== name);
+				this.saveSite(type);
+			}
+		},
+		addCharger(name) {
+			this.$refs.loadpointModal?.setCharger(name);
+		},
+		removeCharger() {
+			this.$refs.loadpointModal?.setCharger(undefined);
+		},
+		meterModalClosed() {
+			if (this.selectedMeterType === "charge") {
+				// reopen loadpoint modal
+				this.loadpointModal().show();
 			}
-			this.saveSite(type);
+		},
+		chargerModalClosed() {
+			// reopen loadpoint modal
+			this.loadpointModal().show();
 		},
 		async saveSite(key) {
 			const body = key ? { [key]: this.site[key] } : this.site;
@@ -619,11 +779,15 @@ export default {
 		async updateValues() {
 			clearTimeout(this.deviceValueTimeout);
 			if (!this.offline) {
-				for (const meter of this.meters) {
-					await this.updateDeviceValue("meter", meter.name);
-				}
-				for (const vehicle of this.vehicles) {
-					await this.updateDeviceValue("vehicle", vehicle.name);
+				const devices = {
+					meter: this.meters,
+					vehicle: this.vehicles,
+					charger: this.chargers,
+				};
+				for (const type in devices) {
+					for (const device of devices[type]) {
+						await this.updateDeviceValue(type, device.name);
+					}
 				}
 			}
 			// ensure that component is still mounted
@@ -632,7 +796,13 @@ export default {
 			this.deviceValueTimeout = setTimeout(this.updateValues, interval);
 		},
 		deviceTags(type, id) {
-			return this.deviceValues[type]?.[id] || [];
+			return this.deviceValues[type]?.[id] || {};
+		},
+		loadpointTags(loadpoint) {
+			const { charger, meter } = loadpoint;
+			const chargerTags = charger ? this.deviceTags("charger", charger) : {};
+			const meterTags = meter ? this.deviceTags("meter", meter) : {};
+			return { ...chargerTags, ...meterTags };
 		},
 		openModal(id) {
 			const $el = document.getElementById(id);
@@ -643,7 +813,8 @@ export default {
 			}
 		},
 		circuitTags(circuit) {
-			const data = store.state?.circuits[circuit.name] || {};
+			const circuits = store.state?.circuits || {};
+			const data = circuits[circuit.name] || {};
 			const result = {};
 			if (data.maxPower) {
 				result.powerRange = {
@@ -665,6 +836,10 @@ export default {
 			const fatal = store.state?.fatal || {};
 			return fatal.class === type && fatal.device === name;
 		},
+		chargerIcon(chargerName) {
+			const charger = this.chargers.find((c) => c.name === chargerName);
+			return charger?.config?.icon;
+		},
 	},
 };
 </script>
diff --git a/cmd/demo.yaml b/cmd/demo.yaml
index c54a5b7e..586f69aa 100644
--- a/cmd/demo.yaml
+++ b/cmd/demo.yaml
@@ -262,7 +262,6 @@ tariffs:
   feedin:
     type: fixed
     price: 0.08 # EUR/kWh
-  co2:
-    type: template
-    template: grünstromindex
-    zip: 12349
+  #co2:
+  #  type: grünstromindex
+  #  zip: 12349
diff --git a/cmd/migrate.go b/cmd/migrate.go
index d384e4be..d4d71bc4 100644
--- a/cmd/migrate.go
+++ b/cmd/migrate.go
@@ -2,7 +2,9 @@ package cmd
 
 import (
 	"github.com/evcc-io/evcc/core/keys"
+	"github.com/evcc-io/evcc/server/db"
 	"github.com/evcc-io/evcc/server/db/settings"
+	"github.com/evcc-io/evcc/util/config"
 	"github.com/spf13/cobra"
 )
 
@@ -34,83 +36,95 @@ func runMigrate(cmd *cobra.Command, args []string) {
 
 	// TODO remove yaml file
 	if reset {
-		log.WARN.Println("resetting:")
+		log.INFO.Println("resetting:")
 	} else {
-		log.WARN.Println("migrating:")
+		log.INFO.Println("migrating:")
 	}
 
-	log.DEBUG.Println("- global settings")
+	log.INFO.Println("- global settings")
 	if reset {
 		settings.Delete(keys.Interval)
 		settings.Delete(keys.SponsorToken)
+		settings.Delete(keys.Title)
 	} else {
 		settings.SetInt(keys.Interval, int64(conf.Interval))
 		settings.SetString(keys.SponsorToken, conf.SponsorToken)
 	}
 
-	log.DEBUG.Println("- network")
+	log.INFO.Println("- network")
 	if reset {
 		settings.Delete(keys.Network)
 	} else {
 		_ = settings.SetJson(keys.Network, conf.Network)
 	}
 
-	log.DEBUG.Println("- mqtt")
+	log.INFO.Println("- mqtt")
 	if reset {
 		settings.Delete(keys.Mqtt)
 	} else {
 		_ = settings.SetJson(keys.Mqtt, conf.Mqtt)
 	}
 
-	log.DEBUG.Println("- influx")
+	log.INFO.Println("- influx")
 	if reset {
 		settings.Delete(keys.Influx)
 	} else {
 		_ = settings.SetJson(keys.Influx, conf.Influx)
 	}
 
-	log.DEBUG.Println("- hems")
+	log.INFO.Println("- hems")
 	if reset {
 		settings.Delete(keys.Hems)
 	} else if conf.HEMS.Type != "" {
 		_ = settings.SetYaml(keys.Hems, conf.HEMS)
 	}
 
-	log.DEBUG.Println("- eebus")
+	log.INFO.Println("- eebus")
 	if reset {
 		settings.Delete(keys.EEBus)
 	} else if conf.EEBus.Configured() {
 		_ = settings.SetYaml(keys.EEBus, conf.EEBus)
 	}
 
-	log.DEBUG.Println("- modbusproxy")
+	log.INFO.Println("- modbusproxy")
 	if reset {
 		settings.Delete(keys.ModbusProxy)
 	} else if len(conf.ModbusProxy) > 0 {
 		_ = settings.SetYaml(keys.ModbusProxy, conf.ModbusProxy)
 	}
 
-	log.DEBUG.Println("- messaging")
+	log.INFO.Println("- messaging")
 	if reset {
 		settings.Delete(keys.Messaging)
 	} else if len(conf.Messaging.Services) > 0 {
 		_ = settings.SetYaml(keys.Messaging, conf.Messaging)
 	}
 
-	log.DEBUG.Println("- tariffs")
+	log.INFO.Println("- tariffs")
 	if reset {
 		settings.Delete(keys.Tariffs)
 	} else if conf.Tariffs.Grid.Type != "" || conf.Tariffs.FeedIn.Type != "" || conf.Tariffs.Co2.Type != "" || conf.Tariffs.Planner.Type != "" {
 		_ = settings.SetYaml(keys.Tariffs, conf.Tariffs)
 	}
 
-	log.DEBUG.Println("- circuits")
+	log.INFO.Println("- circuits")
 	if reset {
 		settings.Delete(keys.Circuits)
 	} else if len(conf.Circuits) > 0 {
 		_ = settings.SetYaml(keys.Circuits, conf.Circuits)
 	}
 
+	log.INFO.Println("- device configs")
+	if reset {
+		// site keys
+		settings.Delete(keys.GridMeter)
+		settings.Delete(keys.PvMeters)
+		settings.Delete(keys.AuxMeters)
+		settings.Delete(keys.BatteryMeters)
+		// clear config table
+		result := db.Instance.Delete(&config.Config{}, "true")
+		log.INFO.Printf("  %d entries deleted", result.RowsAffected)
+	}
 	// wait for shutdown
 	<-shutdownDoneC()
 }
diff --git a/cmd/refs.go b/cmd/refs.go
new file mode 100644
index 00000000..f7598815
--- /dev/null
+++ b/cmd/refs.go
@@ -0,0 +1,98 @@
+package cmd
+
+import (
+	"iter"
+	"slices"
+	"strings"
+
+	"github.com/evcc-io/evcc/api/globalconfig"
+	"github.com/evcc-io/evcc/core"
+	"github.com/evcc-io/evcc/core/keys"
+	"github.com/evcc-io/evcc/server/db/settings"
+	"github.com/evcc-io/evcc/util"
+	"github.com/evcc-io/evcc/util/config"
+	"github.com/evcc-io/evcc/util/templates"
+)
+
+var references struct {
+	meter, charger, vehicle, circuit []string
+}
+
+func collectRefs(conf globalconfig.All) error {
+	// site
+	if err := collectSiteRefs(conf); err != nil {
+		return err
+	}
+
+	// loadpoints
+	if err := collectLoadpointRefs(slices.Values(conf.Loadpoints)); err != nil {
+		return err
+	}
+
+	// append devices from database
+	configurable, err := config.ConfigurationsByClass(templates.Loadpoint)
+	if err != nil {
+		return err
+	}
+
+	return collectLoadpointRefs(func(yield func(config.Named) bool) {
+		for _, cc := range configurable {
+			if !yield(cc.Named()) {
+				return
+			}
+		}
+	})
+}
+
+func collectSiteRefs(conf globalconfig.All) error {
+	var refs struct {
+		Meters core.MetersConfig `mapstructure:"meters"` // Meter references
+		Other  map[string]any    `mapstructure:",remain"`
+	}
+
+	if err := util.DecodeOther(conf.Site, &refs); err != nil {
+		return err
+	}
+
+	references.meter = append(references.meter, refs.Meters.GridMeterRef)
+	references.meter = append(references.meter, refs.Meters.PVMetersRef...)
+	references.meter = append(references.meter, refs.Meters.BatteryMetersRef...)
+	references.meter = append(references.meter, refs.Meters.ExtMetersRef...)
+	references.meter = append(references.meter, refs.Meters.AuxMetersRef...)
+
+	// append devices from settings
+	if v, err := settings.String(keys.GridMeter); err == nil && v != "" {
+		references.meter = append(references.meter, v)
+	}
+
+	for _, key := range []string{keys.PvMeters, keys.BatteryMeters, keys.ExtMeters, keys.AuxMeters} {
+		if v, err := settings.String(key); err == nil && v != "" {
+			references.meter = append(references.meter, strings.Split(v, ",")...)
+		}
+	}
+
+	return nil
+}
+
+func collectLoadpointRefs(named iter.Seq[config.Named]) error {
+	for cc := range named {
+		var refs struct {
+			CircuitRef string         `mapstructure:"circuit"` // Circuit reference
+			ChargerRef string         `mapstructure:"charger"` // Charger reference
+			VehicleRef string         `mapstructure:"vehicle"` // Vehicle reference
+			MeterRef   string         `mapstructure:"meter"`   // Charge meter reference
+			Other      map[string]any `mapstructure:",remain"`
+		}
+
+		if err := util.DecodeOther(cc.Other, &refs); err != nil {
+			return err
+		}
+
+		references.meter = append(references.meter, refs.MeterRef)
+		references.charger = append(references.charger, refs.ChargerRef)
+		references.vehicle = append(references.vehicle, refs.VehicleRef)
+		references.circuit = append(references.circuit, refs.CircuitRef)
+	}
+
+	return nil
+}
diff --git a/cmd/setup.go b/cmd/setup.go
index 5cbeae6b..f7dd80aa 100644
--- a/cmd/setup.go
+++ b/cmd/setup.go
@@ -22,6 +22,8 @@ import (
 	"github.com/evcc-io/evcc/core"
 	"github.com/evcc-io/evcc/core/circuit"
 	"github.com/evcc-io/evcc/core/keys"
+	"github.com/evcc-io/evcc/core/loadpoint"
+	coresettings "github.com/evcc-io/evcc/core/settings"
 	"github.com/evcc-io/evcc/hems"
 	"github.com/evcc-io/evcc/meter"
 	"github.com/evcc-io/evcc/plugin/golang"
@@ -46,6 +48,7 @@ import (
 	"github.com/gorilla/handlers"
 	"github.com/gorilla/mux"
 	"github.com/libp2p/zeroconf/v2"
+	"github.com/samber/lo"
 	"github.com/spf13/cast"
 	"github.com/spf13/cobra"
 	"golang.org/x/sync/errgroup"
@@ -262,7 +265,7 @@ func configureMeters(static []config.Named, names ...string) error {
 				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 {
+			if err := config.Meters().Add(config.NewConfigurableDevice(&conf, instance)); err != nil {
 				return &DeviceError{cc.Name, err}
 			}
 
@@ -326,7 +329,7 @@ func configureChargers(static []config.Named, names ...string) error {
 				return fmt.Errorf("cannot create charger '%s': %w", cc.Name, err)
 			}
 
-			if err := config.Chargers().Add(config.NewConfigurableDevice(conf, instance)); err != nil {
+			if err := config.Chargers().Add(config.NewConfigurableDevice(&conf, instance)); err != nil {
 				return &DeviceError{cc.Name, err}
 			}
 
@@ -419,7 +422,7 @@ func configureVehicles(static []config.Named, names ...string) error {
 
 			mu.Lock()
 			defer mu.Unlock()
-			devs2 = append(devs2, config.NewConfigurableDevice(conf, instance))
+			devs2 = append(devs2, config.NewConfigurableDevice(&conf, instance))
 
 			return nil
 		})
@@ -796,11 +799,16 @@ func configureTariffs(conf globalconfig.Tariffs) (*tariff.Tariffs, error) {
 }
 
 func configureDevices(conf globalconfig.All) error {
+	// collect references for filtering used devices
+	if err := collectRefs(conf); err != nil {
+		return err
+	}
+
 	// TODO: add name/identifier to error for better highlighting in UI
-	if err := configureMeters(conf.Meters); err != nil {
+	if err := configureMeters(conf.Meters, references.meter...); err != nil {
 		return &ClassError{ClassMeter, err}
 	}
-	if err := configureChargers(conf.Chargers); err != nil {
+	if err := configureChargers(conf.Chargers, references.charger...); err != nil {
 		return &ClassError{ClassCharger, err}
 	}
 	if err := configureVehicles(conf.Vehicles); err != nil {
@@ -853,8 +861,7 @@ func configureSiteAndLoadpoints(conf *globalconfig.All) (*core.Site, error) {
 		return nil, err
 	}
 
-	loadpoints, err := configureLoadpoints(*conf)
-	if err != nil {
+	if err := configureLoadpoints(*conf); err != nil {
 		return nil, fmt.Errorf("failed configuring loadpoints: %w", err)
 	}
 
@@ -863,6 +870,11 @@ func configureSiteAndLoadpoints(conf *globalconfig.All) (*core.Site, error) {
 		return nil, &ClassError{ClassTariff, err}
 	}
 
+	loadpoints := lo.Map(config.Loadpoints().Devices(), func(dev config.Device[loadpoint.API], _ int) *core.Loadpoint {
+		lp := dev.Instance()
+		return lp.(*core.Loadpoint)
+	})
+
 	site, err := configureSite(conf.Site, loadpoints, tariffs)
 	if err != nil {
 		return nil, err
@@ -924,26 +936,59 @@ func configureSite(conf map[string]interface{}, loadpoints []*core.Loadpoint, ta
 	return site, nil
 }
 
-func configureLoadpoints(conf globalconfig.All) ([]*core.Loadpoint, error) {
-	if len(conf.Loadpoints) == 0 {
-		return nil, errors.New("missing loadpoints")
+func configureLoadpoints(conf globalconfig.All) error {
+	for id, cc := range conf.Loadpoints {
+		cc.Name = "lp-" + strconv.Itoa(id+1)
+
+		log := util.NewLoggerWithLoadpoint(cc.Name, id+1)
+		settings := coresettings.NewDatabaseSettingsAdapter(fmt.Sprintf("lp%d.", id+1))
+
+		instance, err := core.NewLoadpointFromConfig(log, settings, cc.Other)
+		if err != nil {
+			return fmt.Errorf("failed configuring loadpoint: %w", err)
+		}
+
+		if err := config.Loadpoints().Add(config.NewStaticDevice(cc, loadpoint.API(instance))); err != nil {
+			return &DeviceError{cc.Name, err}
+		}
+	}
+
+	// append devices from database
+	configurable, err := config.ConfigurationsByClass(templates.Loadpoint)
+	if err != nil {
+		return err
 	}
 
-	var loadpoints []*core.Loadpoint
+	for _, conf := range configurable {
+		cc := conf.Named()
 
-	for id, cfg := range conf.Loadpoints {
-		log := util.NewLoggerWithLoadpoint("lp-"+strconv.Itoa(id+1), id+1)
-		settings := &core.Settings{Key: "lp" + strconv.Itoa(id+1) + "."}
+		id := len(config.Loadpoints().Devices())
+		name := "lp-" + strconv.Itoa(id+1)
+		log := util.NewLoggerWithLoadpoint(name, id+1)
+
+		settings := coresettings.NewConfigSettingsAdapter(log, &conf)
+
+		dynamic, static, err := loadpoint.SplitConfig(cc.Other)
+		if err != nil {
+			return fmt.Errorf("failed configuring loadpoint: %w", err)
+		}
 
-		lp, err := core.NewLoadpointFromConfig(log, settings, cfg)
+		instance, err := core.NewLoadpointFromConfig(log, settings, static)
 		if err != nil {
-			return nil, fmt.Errorf("failed configuring loadpoint: %w", err)
+			return fmt.Errorf("failed configuring loadpoint: %w", err)
+		}
+
+		if err := dynamic.Apply(instance); err != nil {
+			return err
 		}
 
-		loadpoints = append(loadpoints, lp)
+		dev := config.NewConfigurableDevice[loadpoint.API](&conf, instance)
+		if err := config.Loadpoints().Add(dev); err != nil {
+			return err
+		}
 	}
 
-	return loadpoints, nil
+	return nil
 }
 
 // configureAuth handles routing for devices. For now only api.AuthProvider related routes
diff --git a/core/keys/loadpoint.go b/core/keys/loadpoint.go
index 4308fddf..543f914b 100644
--- a/core/keys/loadpoint.go
+++ b/core/keys/loadpoint.go
@@ -4,27 +4,31 @@ const (
 	// loadpoint settings
 	Title            = "title"       // loadpoint title
 	Mode             = "mode"        // charge mode
+	DefaultMode      = "defaultMode" // default charge mode
 	Priority         = "priority"    // priority
 	MinCurrent       = "minCurrent"  // min current
 	MaxCurrent       = "maxCurrent"  // max current
 	MinSoc           = "minSoc"      // min soc
 	LimitSoc         = "limitSoc"    // limit soc
 	LimitEnergy      = "limitEnergy" // limit energy
+	Soc              = "soc"
+	Thresholds       = "thresholds"
 	EnableThreshold  = "enableThreshold"
 	DisableThreshold = "disableThreshold"
 	EnableDelay      = "enableDelay"
 	DisableDelay     = "disableDelay"
 	BatteryBoost     = "batteryBoost"
 
-	PhasesConfigured = "phasesConfigured" // configured phases (1/3, 0 for auto on 1p3p chargers, nil for plain chargers)
+	Phases           = "phases"           // configured phases (1/3, 0 for auto on 1p3p chargers, nil for plain chargers)
+	PhasesConfigured = "phasesConfigured" // TODO mirrors "phases" for UI purposes
 	PhasesEnabled    = "phasesEnabled"    // enabled phases (1/3)
 	PhasesActive     = "phasesActive"     // active phases as used by vehicle (1/2/3)
 
-	ChargerIcon           = "chargerIcon"           // charger icon for ui
-	ChargerFeature        = "chargerFeature"        // charger feature
-	ChargerPhysicalPhases = "chargerPhysicalPhases" // charger phases
-	ChargerPhases1p3p     = "chargerPhases1p3p"     // phase switcher (1p3p chargers)
-	ChargerStatusReason   = "chargerStatusReason"   // either awaiting authorization or disconnect required
+	ChargerIcon         = "chargerIcon"         // charger icon for ui
+	ChargerFeature      = "chargerFeature"      // charger feature
+	ChargerSinglePhase  = "chargerSinglePhase"  // api.PhaseDescriber: charger physical phases, sockets only
+	ChargerPhases1p3p   = "chargerPhases1p3p"   // api.PhaseSwitcher: 1p3p chargers
+	ChargerStatusReason = "chargerStatusReason" // either awaiting authorization or disconnect required
 
 	// loadpoint status
 	Enabled   = "enabled"   // loadpoint enabled
diff --git a/core/loadpoint.go b/core/loadpoint.go
index e6cff397..3f761e25 100644
--- a/core/loadpoint.go
+++ b/core/loadpoint.go
@@ -6,7 +6,6 @@ import (
 	"math"
 	"reflect"
 	"slices"
-	"strings"
 	"sync"
 	"testing"
 	"time"
@@ -20,6 +19,7 @@ import (
 	"github.com/evcc-io/evcc/core/loadpoint"
 	"github.com/evcc-io/evcc/core/planner"
 	"github.com/evcc-io/evcc/core/session"
+	"github.com/evcc-io/evcc/core/settings"
 	"github.com/evcc-io/evcc/core/soc"
 	"github.com/evcc-io/evcc/core/vehicle"
 	"github.com/evcc-io/evcc/core/wrapper"
@@ -64,32 +64,8 @@ const (
 // elapsed is the time an expired timer will be set to
 var elapsed = time.Unix(0, 1)
 
-// PollConfig defines the vehicle polling mode and interval
-type PollConfig struct {
-	Mode     string        `mapstructure:"mode"`     // polling mode charging (default), connected, always
-	Interval time.Duration `mapstructure:"interval"` // interval when not charging
-}
-
-// SocConfig defines soc settings, estimation and update behavior
-type SocConfig struct {
-	Poll     PollConfig `mapstructure:"poll"`
-	Estimate *bool      `mapstructure:"estimate"`
-}
-
 // Poll modes
-const (
-	pollCharging  = "charging"
-	pollConnected = "connected"
-	pollAlways    = "always"
-
-	pollInterval = 60 * time.Minute
-)
-
-// ThresholdConfig defines enable/disable hysteresis parameters
-type ThresholdConfig struct {
-	Delay     time.Duration
-	Threshold float64
-}
+const pollInterval = 60 * time.Minute
 
 // Task is the task type
 type Task = func()
@@ -107,24 +83,27 @@ type Loadpoint struct {
 	// exposed public configuration
 	sync.RWMutex // guard status
 
-	vmu   sync.RWMutex   // guard vehicle
-	Mode_ api.ChargeMode `mapstructure:"mode"` // Default charge mode, used for disconnect
+	vmu sync.RWMutex // guard vehicle
 
-	Title_          string `mapstructure:"title"`    // UI title
-	Priority_       int    `mapstructure:"priority"` // Priority
-	CircuitRef      string `mapstructure:"circuit"`  // Circuit reference
-	ChargerRef      string `mapstructure:"charger"`  // Charger reference
-	VehicleRef      string `mapstructure:"vehicle"`  // Vehicle reference
-	MeterRef        string `mapstructure:"meter"`    // Charge meter reference
-	Soc             SocConfig
-	Enable, Disable ThresholdConfig
+	CircuitRef string `mapstructure:"circuit"` // Circuit reference
+	ChargerRef string `mapstructure:"charger"` // Charger reference
+	VehicleRef string `mapstructure:"vehicle"` // Vehicle reference
+	MeterRef   string `mapstructure:"meter"`   // Charge meter reference
 
-	// TODO deprecated
-	GuardDuration_    time.Duration `mapstructure:"guardduration"` // charger enable/disable minimum holding time
-	ConfiguredPhases_ int           `mapstructure:"phases"`
-	MinCurrent_       float64       `mapstructure:"minCurrent"`
-	MaxCurrent_       float64       `mapstructure:"maxCurrent"`
+	Soc             loadpoint.SocConfig
+	Enable, Disable loadpoint.ThresholdConfig
 
+	// TODO deprecated
+	DefaultMode    api.ChargeMode `mapstructure:"mode"`          // Default charge mode, used for disconnect
+	Title_         string         `mapstructure:"title"`         // UI title
+	Priority_      int            `mapstructure:"priority"`      // Priority
+	GuardDuration_ time.Duration  `mapstructure:"guardduration"` // charger enable/disable minimum holding time
+	Phases_        int            `mapstructure:"phases"`
+	MinCurrent_    float64        `mapstructure:"minCurrent"`
+	MaxCurrent_    float64        `mapstructure:"maxCurrent"`
+
+	title            string   // UI title
+	priority         int      // Priority
 	minCurrent       float64  // PV mode: start current	Min+PV mode: min current
 	maxCurrent       float64  // Max allowed current. Physically ensured by the charger
 	configuredPhases int      // Charger configured phase mode 0/1/3
@@ -186,34 +165,31 @@ type Loadpoint struct {
 	db      *session.DB
 	session *session.Session
 
-	settings *Settings
+	settings settings.Settings
 
 	tasks *util.Queue[Task] // tasks to be executed
 }
 
 // NewLoadpointFromConfig creates a new loadpoint
-func NewLoadpointFromConfig(log *util.Logger, settings *Settings, other map[string]interface{}) (*Loadpoint, error) {
+func NewLoadpointFromConfig(log *util.Logger, settings settings.Settings, other map[string]interface{}) (*Loadpoint, error) {
 	lp := NewLoadpoint(log, settings)
 	if err := util.DecodeOther(other, lp); err != nil {
 		return nil, err
 	}
 
 	// set vehicle polling mode
-	switch lp.Soc.Poll.Mode = strings.ToLower(lp.Soc.Poll.Mode); lp.Soc.Poll.Mode {
-	case pollCharging:
-	case pollConnected, pollAlways:
+	switch lp.Soc.Poll.Mode {
+	case loadpoint.PollCharging:
+	case loadpoint.PollConnected, loadpoint.PollAlways:
 		lp.log.WARN.Printf("poll mode '%s' may deplete your battery or lead to API misuse. USE AT YOUR OWN RISK.", lp.Soc.Poll)
 	default:
-		if lp.Soc.Poll.Mode != "" {
-			lp.log.WARN.Printf("invalid poll mode: %s", lp.Soc.Poll.Mode)
-		}
-		lp.Soc.Poll.Mode = pollCharging
+		lp.Soc.Poll.Mode = loadpoint.PollCharging
 	}
 
 	if lp.CircuitRef != "" {
 		dev, err := config.Circuits().ByName(lp.CircuitRef)
 		if err != nil {
-			return nil, err
+			return nil, fmt.Errorf("circuit: %w", err)
 		}
 		lp.circuit = dev.Instance()
 	}
@@ -221,7 +197,7 @@ func NewLoadpointFromConfig(log *util.Logger, settings *Settings, other map[stri
 	if lp.MeterRef != "" {
 		dev, err := config.Meters().ByName(lp.MeterRef)
 		if err != nil {
-			return nil, err
+			return nil, fmt.Errorf("meter: %w", err)
 		}
 		lp.chargeMeter = dev.Instance()
 	}
@@ -230,7 +206,7 @@ func NewLoadpointFromConfig(log *util.Logger, settings *Settings, other map[stri
 	if lp.VehicleRef != "" {
 		dev, err := config.Vehicles().ByName(lp.VehicleRef)
 		if err != nil {
-			return nil, err
+			return nil, fmt.Errorf("vehicle: %w", err)
 		}
 		lp.defaultVehicle = dev.Instance()
 	}
@@ -240,7 +216,7 @@ func NewLoadpointFromConfig(log *util.Logger, settings *Settings, other map[stri
 	}
 	dev, err := config.Chargers().ByName(lp.ChargerRef)
 	if err != nil {
-		return nil, err
+		return nil, fmt.Errorf("charger: %w", err)
 	}
 	lp.charger = dev.Instance()
 	lp.configureChargerType(lp.charger)
@@ -252,24 +228,7 @@ func NewLoadpointFromConfig(log *util.Logger, settings *Settings, other map[stri
 	}
 
 	// TODO deprecated
-	if lp.MinCurrent_ > 0 {
-		lp.log.WARN.Println("deprecated: mincurrent setting is ignored, please remove")
-		if _, err := lp.settings.Float(keys.MinCurrent); err != nil {
-			lp.settings.SetFloat(keys.MinCurrent, lp.MinCurrent_)
-		}
-	}
-	if lp.MaxCurrent_ > 0 {
-		lp.log.WARN.Println("deprecated: maxcurrent setting is ignored, please remove")
-		if _, err := lp.settings.Float(keys.MaxCurrent); err != nil {
-			lp.settings.SetFloat(keys.MaxCurrent, lp.MaxCurrent_)
-		}
-	}
-	if lp.ConfiguredPhases_ > 0 {
-		lp.log.WARN.Println("deprecated: phases setting is ignored, please remove")
-		if _, err := lp.settings.Int(keys.PhasesConfigured); err != nil {
-			lp.settings.SetInt(keys.PhasesConfigured, int64(lp.ConfiguredPhases_))
-		}
-	}
+	lp.migrateSettings()
 
 	// validate thresholds
 	if lp.Enable.Threshold > lp.Disable.Threshold {
@@ -279,7 +238,7 @@ func NewLoadpointFromConfig(log *util.Logger, settings *Settings, other map[stri
 	}
 
 	// choose sane default if mode is not set
-	if lp.mode = lp.Mode_; lp.mode == "" {
+	if lp.mode = lp.DefaultMode; lp.mode == "" {
 		lp.mode = api.ModeOff
 	}
 
@@ -287,7 +246,7 @@ func NewLoadpointFromConfig(log *util.Logger, settings *Settings, other map[stri
 }
 
 // NewLoadpoint creates a Loadpoint with sane defaults
-func NewLoadpoint(log *util.Logger, settings *Settings) *Loadpoint {
+func NewLoadpoint(log *util.Logger, settings settings.Settings) *Loadpoint {
 	clock := clock.New()
 	bus := evbus.New()
 
@@ -300,31 +259,84 @@ func NewLoadpoint(log *util.Logger, settings *Settings) *Loadpoint {
 		status:     api.StatusNone,
 		minCurrent: 6,  // A
 		maxCurrent: 16, // A
-		Soc: SocConfig{
-			Poll: PollConfig{
+		Soc: loadpoint.SocConfig{
+			Poll: loadpoint.PollConfig{
 				Interval: pollInterval,
-				Mode:     pollCharging,
+				Mode:     loadpoint.PollCharging,
 			},
 		},
-		Enable:      ThresholdConfig{Delay: time.Minute, Threshold: 0},     // t, W
-		Disable:     ThresholdConfig{Delay: 3 * time.Minute, Threshold: 0}, // t, W
-		progress:    NewProgress(0, 10),                                    // soc progress indicator
-		coordinator: coordinator.NewDummy(),                                // dummy vehicle coordinator
-		tasks:       util.NewQueue[Task](),                                 // task queue
+		Enable:      loadpoint.ThresholdConfig{Delay: time.Minute, Threshold: 0},     // t, W
+		Disable:     loadpoint.ThresholdConfig{Delay: 3 * time.Minute, Threshold: 0}, // t, W
+		progress:    NewProgress(0, 10),                                              // soc progress indicator
+		coordinator: coordinator.NewDummy(),                                          // dummy vehicle coordinator
+		tasks:       util.NewQueue[Task](),                                           // task queue
 	}
 
 	return lp
 }
 
+// migrateSettings migrates loadpoint settings
+func (lp *Loadpoint) migrateSettings() {
+	// One-time migrations MUST be mirrored in restoreSettings
+	if lp.DefaultMode != "" {
+		lp.log.WARN.Println("deprecated: mode setting is ignored, please remove")
+		if _, err := lp.settings.String(keys.Mode); err != nil {
+			lp.settings.SetString(keys.Mode, string(lp.DefaultMode))
+		}
+	}
+	if lp.Title_ != "" {
+		lp.log.WARN.Println("deprecated: title setting is ignored, please remove")
+		if _, err := lp.settings.String(keys.Title); err != nil {
+			lp.settings.SetString(keys.Title, lp.Title_)
+		}
+	}
+	if lp.Priority_ > 0 {
+		lp.log.WARN.Println("deprecated: priority setting is ignored, please remove")
+		if _, err := lp.settings.String(keys.Priority); err != nil {
+			lp.settings.SetInt(keys.Priority, int64(lp.Priority_))
+		}
+	}
+	if lp.MinCurrent_ > 0 {
+		lp.log.WARN.Println("deprecated: mincurrent setting is ignored, please remove")
+		if _, err := lp.settings.Float(keys.MinCurrent); err != nil {
+			lp.settings.SetFloat(keys.MinCurrent, lp.MinCurrent_)
+		}
+	}
+	if lp.MaxCurrent_ > 0 {
+		lp.log.WARN.Println("deprecated: maxcurrent setting is ignored, please remove")
+		if _, err := lp.settings.Float(keys.MaxCurrent); err != nil {
+			lp.settings.SetFloat(keys.MaxCurrent, lp.MaxCurrent_)
+		}
+	}
+	if lp.Phases_ > 0 {
+		lp.log.WARN.Println("deprecated: phases setting is ignored, please remove")
+		if _, err := lp.settings.Int(keys.Phases); err != nil {
+			lp.settings.SetInt(keys.Phases, int64(lp.Phases_))
+		}
+	}
+	if lp.Soc.Estimate != nil || lp.Soc.Poll.Mode != loadpoint.PollCharging || lp.Soc.Poll.Interval != 0 {
+		lp.log.WARN.Println("deprecated: soc setting is ignored, please remove")
+		if _, err := lp.settings.String(keys.Soc); err != nil {
+			lp.settings.SetJson(keys.Soc, lp.Soc)
+		}
+	}
+}
+
 // restoreSettings restores loadpoint settings
 func (lp *Loadpoint) restoreSettings() {
 	if testing.Testing() {
 		return
 	}
+	if v, err := lp.settings.String(keys.Title); err == nil && v != "" {
+		lp.setTitle(v)
+	}
 	if v, err := lp.settings.String(keys.Mode); err == nil && v != "" {
 		lp.setMode(api.ChargeMode(v))
 	}
-	if v, err := lp.settings.Int(keys.PhasesConfigured); err == nil && (v > 0 || lp.hasPhaseSwitching()) {
+	if v, err := lp.settings.Int(keys.Priority); err == nil && v > 0 {
+		lp.setPriority(int(v))
+	}
+	if v, err := lp.settings.Int(keys.Phases); err == nil && (v > 0 || lp.hasPhaseSwitching()) {
 		lp.setConfiguredPhases(int(v))
 		lp.phases = lp.configuredPhases
 	}
@@ -344,6 +356,16 @@ func (lp *Loadpoint) restoreSettings() {
 		lp.SetSmartCostLimit(&v)
 	}
 
+	var thresholds loadpoint.ThresholdsConfig
+	if err := lp.settings.Json(keys.Thresholds, &thresholds); err == nil {
+		lp.setThresholds(thresholds)
+	}
+
+	var socConfig loadpoint.SocConfig
+	if err := lp.settings.Json(keys.Soc, &socConfig); err == nil {
+		lp.setSocConfig(socConfig)
+	}
+
 	t, err1 := lp.settings.Time(keys.PlanTime)
 	v, err2 := lp.settings.Float(keys.PlanEnergy)
 	if err1 == nil && err2 == nil {
@@ -584,7 +606,7 @@ func (lp *Loadpoint) evChargeCurrentWrappedMeterHandler(current float64) {
 // defaultMode executes the action
 func (lp *Loadpoint) defaultMode() {
 	lp.RLock()
-	mode := lp.Mode_
+	mode := lp.DefaultMode
 	lp.RUnlock()
 
 	if mode != "" && mode != lp.GetMode() {
@@ -610,7 +632,7 @@ func (lp *Loadpoint) Prepare(uiChan chan<- util.Param, pushChan chan<- push.Even
 	lp.restoreSettings()
 
 	// publish initial values
-	lp.publish(keys.Title, lp.Title())
+	lp.publish(keys.Title, lp.GetTitle())
 	lp.publish(keys.Mode, lp.GetMode())
 	lp.publish(keys.Priority, lp.GetPriority())
 	lp.publish(keys.MinCurrent, lp.GetMinCurrent())
@@ -627,15 +649,17 @@ func (lp *Loadpoint) Prepare(uiChan chan<- util.Param, pushChan chan<- push.Even
 		}
 		lp.phases = phases
 		lp.configuredPhases = phases
-		lp.publish(keys.ChargerPhysicalPhases, phases)
+		lp.publish(keys.Phases, phases)
 	} else {
-		lp.publish(keys.ChargerPhysicalPhases, nil)
+		lp.publish(keys.Phases, nil)
 	}
 
 	lp.publish(keys.PhasesConfigured, lp.configuredPhases)
 	lp.publish(keys.ChargerPhases1p3p, lp.hasPhaseSwitching())
+	lp.publish(keys.ChargerSinglePhase, lp.getChargerPhysicalPhases() == 1)
 	lp.publish(keys.PhasesEnabled, lp.phases)
 	lp.publish(keys.PhasesActive, lp.ActivePhases())
+	lp.publish(keys.SmartCostLimit, lp.smartCostLimit)
 	lp.publishTimer(phaseTimer, 0, timerInactive)
 	lp.publishTimer(pvTimer, 0, timerInactive)
 
@@ -685,6 +709,11 @@ func (lp *Loadpoint) Prepare(uiChan chan<- util.Param, pushChan chan<- push.Even
 		lp.log.ERROR.Printf("charger enabled: %v", err)
 	}
 
+	// set vehicle polling mode
+	if lp.Soc.Poll.Mode != loadpoint.PollCharging {
+		lp.log.WARN.Printf("poll mode '%s' may deplete your battery or lead to API misuse. USE AT YOUR OWN RISK.", lp.Soc.Poll)
+	}
+
 	// allow charger to access loadpoint
 	if ctrl, ok := lp.charger.(loadpoint.Controller); ok {
 		ctrl.LoadpointControl(lp)
diff --git a/core/loadpoint/api.go b/core/loadpoint/api.go
index e98511f1..be135728 100644
--- a/core/loadpoint/api.go
+++ b/core/loadpoint/api.go
@@ -22,12 +22,29 @@ type API interface {
 	// GetStatus returns the charging status
 	GetStatus() api.ChargeStatus
 
+	//
+	// references
+	//
+
+	// GetCharger returns the loadpoint charger
+	GetChargerName() string
+	// GetMeter returns the loadpoint meter
+	GetMeterName() string
+	// GetCircuitName returns the loadpoint circuit name
+	GetCircuitName() string
+	// GetCircuit returns the loadpoint circuit
+	GetCircuit() api.Circuit
+	// GetDefaultVehicle returns the loadpoint default vehicle
+	GetDefaultVehicle() string
+
 	//
 	// settings
 	//
 
-	// Title returns the defined loadpoint title
-	Title() string
+	// GetTitle returns the loadpoint title
+	GetTitle() string
+	// SetTitle sets the loadpoint title
+	SetTitle(string)
 	// GetPriority returns the priority
 	GetPriority() int
 	// SetPriority sets the priority
@@ -41,10 +58,14 @@ type API interface {
 	// SetMaxCurrent sets the max charging current
 	SetMaxCurrent(float64) error
 
-	// GetMode returns the charge mode
+	// GetMode returns the current charge mode
 	GetMode() api.ChargeMode
 	// SetMode sets the charge mode
 	SetMode(api.ChargeMode)
+	// GetDefaultMode returns the default charge mode (for reset)
+	GetDefaultMode() api.ChargeMode
+	// SetDefaultMode sets the default charge mode (for reset)
+	SetDefaultMode(api.ChargeMode)
 	// GetPhases returns the enabled phases
 	GetPhases() int
 	// SetPhases sets the enabled phases
@@ -95,6 +116,15 @@ type API interface {
 	// GetPlan creates a charging plan
 	GetPlan(targetTime time.Time, requiredDuration time.Duration) (api.Rates, error)
 
+	// GetSocConfig returns the soc poll settings
+	GetSocConfig() SocConfig
+	// SetSocConfig sets the soc poll settings
+	SetSocConfig(soc SocConfig)
+
+	// GetThresholds returns the PV mode threshold settings
+	GetThresholds() ThresholdsConfig
+	// SetThresholds sets the PV mode threshold settings
+	SetThresholds(thresholds ThresholdsConfig)
 	// GetEnableThreshold gets the loadpoint enable threshold
 	GetEnableThreshold() float64
 	// SetEnableThreshold sets loadpoint enable threshold
@@ -164,7 +194,4 @@ type API interface {
 	SetVehicle(vehicle api.Vehicle)
 	// StartVehicleDetection allows triggering vehicle detection for debugging purposes
 	StartVehicleDetection()
-
-	// GetCircuit gets the assigned circuit
-	GetCircuit() api.Circuit
 }
diff --git a/core/loadpoint/config.go b/core/loadpoint/config.go
new file mode 100644
index 00000000..6834a222
--- /dev/null
+++ b/core/loadpoint/config.go
@@ -0,0 +1,83 @@
+package loadpoint
+
+import (
+	"time"
+
+	"github.com/evcc-io/evcc/api"
+	"github.com/evcc-io/evcc/util"
+)
+
+type StaticConfig struct {
+	// static config
+	Charger string `json:"charger,omitempty"`
+	Meter   string `json:"meter,omitempty"`
+	Circuit string `json:"circuit,omitempty"`
+	Vehicle string `json:"vehicle,omitempty"`
+}
+
+type DynamicConfig struct {
+	// dynamic config
+	Title          string    `json:"title"`
+	DefaultMode    string    `json:"defaultMode"`
+	Priority       int       `json:"priority"`
+	Phases         int       `json:"phases"`
+	MinCurrent     float64   `json:"minCurrent"`
+	MaxCurrent     float64   `json:"maxCurrent"`
+	SmartCostLimit *float64  `json:"smartCostLimit"`
+	PlanEnergy     float64   `json:"planEnergy"`
+	PlanTime       time.Time `json:"planTime"`
+	LimitEnergy    float64   `json:"limitEnergy"`
+	LimitSoc       int       `json:"limitSoc"`
+
+	Thresholds ThresholdsConfig `json:"thresholds"`
+	Soc        SocConfig        `json:"soc"`
+}
+
+func SplitConfig(payload map[string]any) (DynamicConfig, map[string]any, error) {
+	// split static and dynamic config via mapstructure
+	var cc struct {
+		DynamicConfig `mapstructure:",squash"`
+		Other         map[string]any `mapstructure:",remain"`
+	}
+
+	if err := util.DecodeOther(payload, &cc); err != nil {
+		return DynamicConfig{}, nil, err
+	}
+
+	// TODO: proper handling of id/name
+	delete(cc.Other, "id")
+	delete(cc.Other, "name")
+
+	return cc.DynamicConfig, cc.Other, nil
+}
+
+func (payload DynamicConfig) Apply(lp API) error {
+	lp.SetTitle(payload.Title)
+	lp.SetPriority(payload.Priority)
+	lp.SetSmartCostLimit(payload.SmartCostLimit)
+	lp.SetThresholds(payload.Thresholds)
+	lp.SetPlanEnergy(payload.PlanTime, payload.PlanEnergy)
+	lp.SetLimitEnergy(payload.LimitEnergy)
+	lp.SetLimitSoc(payload.LimitSoc)
+
+	// TODO mode warning
+	lp.SetSocConfig(payload.Soc)
+
+	mode, err := api.ChargeModeString(payload.DefaultMode)
+	if err == nil {
+		lp.SetDefaultMode(mode)
+	}
+
+	if err == nil {
+		err = lp.SetPhases(payload.Phases)
+	}
+
+	if err == nil && payload.MinCurrent != 0 {
+		err = lp.SetMinCurrent(payload.MinCurrent)
+	}
+	if err == nil && payload.MaxCurrent != 0 {
+		err = lp.SetMaxCurrent(payload.MaxCurrent)
+	}
+
+	return err
+}
diff --git a/core/loadpoint/mock.go b/core/loadpoint/mock.go
index c50aade3..7664457d 100644
--- a/core/loadpoint/mock.go
+++ b/core/loadpoint/mock.go
@@ -167,6 +167,20 @@ func (mr *MockAPIMockRecorder) GetChargePowerFlexibility() *gomock.Call {
 	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetChargePowerFlexibility", reflect.TypeOf((*MockAPI)(nil).GetChargePowerFlexibility))
 }
 
+// GetChargerName mocks base method.
+func (m *MockAPI) GetChargerName() string {
+	m.ctrl.T.Helper()
+	ret := m.ctrl.Call(m, "GetChargerName")
+	ret0, _ := ret[0].(string)
+	return ret0
+}
+
+// GetChargerName indicates an expected call of GetChargerName.
+func (mr *MockAPIMockRecorder) GetChargerName() *gomock.Call {
+	mr.mock.ctrl.T.Helper()
+	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetChargerName", reflect.TypeOf((*MockAPI)(nil).GetChargerName))
+}
+
 // GetCircuit mocks base method.
 func (m *MockAPI) GetCircuit() api.Circuit {
 	m.ctrl.T.Helper()
@@ -181,6 +195,48 @@ func (mr *MockAPIMockRecorder) GetCircuit() *gomock.Call {
 	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetCircuit", reflect.TypeOf((*MockAPI)(nil).GetCircuit))
 }
 
+// GetCircuitName mocks base method.
+func (m *MockAPI) GetCircuitName() string {
+	m.ctrl.T.Helper()
+	ret := m.ctrl.Call(m, "GetCircuitName")
+	ret0, _ := ret[0].(string)
+	return ret0
+}
+
+// GetCircuitName indicates an expected call of GetCircuitName.
+func (mr *MockAPIMockRecorder) GetCircuitName() *gomock.Call {
+	mr.mock.ctrl.T.Helper()
+	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetCircuitName", reflect.TypeOf((*MockAPI)(nil).GetCircuitName))
+}
+
+// GetDefaultMode mocks base method.
+func (m *MockAPI) GetDefaultMode() api.ChargeMode {
+	m.ctrl.T.Helper()
+	ret := m.ctrl.Call(m, "GetDefaultMode")
+	ret0, _ := ret[0].(api.ChargeMode)
+	return ret0
+}
+
+// GetDefaultMode indicates an expected call of GetDefaultMode.
+func (mr *MockAPIMockRecorder) GetDefaultMode() *gomock.Call {
+	mr.mock.ctrl.T.Helper()
+	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetDefaultMode", reflect.TypeOf((*MockAPI)(nil).GetDefaultMode))
+}
+
+// GetDefaultVehicle mocks base method.
+func (m *MockAPI) GetDefaultVehicle() string {
+	m.ctrl.T.Helper()
+	ret := m.ctrl.Call(m, "GetDefaultVehicle")
+	ret0, _ := ret[0].(string)
+	return ret0
+}
+
+// GetDefaultVehicle indicates an expected call of GetDefaultVehicle.
+func (mr *MockAPIMockRecorder) GetDefaultVehicle() *gomock.Call {
+	mr.mock.ctrl.T.Helper()
+	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetDefaultVehicle", reflect.TypeOf((*MockAPI)(nil).GetDefaultVehicle))
+}
+
 // GetDisableDelay mocks base method.
 func (m *MockAPI) GetDisableDelay() time.Duration {
 	m.ctrl.T.Helper()
@@ -293,6 +349,20 @@ func (mr *MockAPIMockRecorder) GetMaxPhaseCurrent() *gomock.Call {
 	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetMaxPhaseCurrent", reflect.TypeOf((*MockAPI)(nil).GetMaxPhaseCurrent))
 }
 
+// GetMeterName mocks base method.
+func (m *MockAPI) GetMeterName() string {
+	m.ctrl.T.Helper()
+	ret := m.ctrl.Call(m, "GetMeterName")
+	ret0, _ := ret[0].(string)
+	return ret0
+}
+
+// GetMeterName indicates an expected call of GetMeterName.
+func (mr *MockAPIMockRecorder) GetMeterName() *gomock.Call {
+	mr.mock.ctrl.T.Helper()
+	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetMeterName", reflect.TypeOf((*MockAPI)(nil).GetMeterName))
+}
+
 // GetMinCurrent mocks base method.
 func (m *MockAPI) GetMinCurrent() float64 {
 	m.ctrl.T.Helper()
@@ -450,6 +520,20 @@ func (mr *MockAPIMockRecorder) GetSmartCostLimit() *gomock.Call {
 	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetSmartCostLimit", reflect.TypeOf((*MockAPI)(nil).GetSmartCostLimit))
 }
 
+// GetSocConfig mocks base method.
+func (m *MockAPI) GetSocConfig() SocConfig {
+	m.ctrl.T.Helper()
+	ret := m.ctrl.Call(m, "GetSocConfig")
+	ret0, _ := ret[0].(SocConfig)
+	return ret0
+}
+
+// GetSocConfig indicates an expected call of GetSocConfig.
+func (mr *MockAPIMockRecorder) GetSocConfig() *gomock.Call {
+	mr.mock.ctrl.T.Helper()
+	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetSocConfig", reflect.TypeOf((*MockAPI)(nil).GetSocConfig))
+}
+
 // GetStatus mocks base method.
 func (m *MockAPI) GetStatus() api.ChargeStatus {
 	m.ctrl.T.Helper()
@@ -464,6 +548,34 @@ func (mr *MockAPIMockRecorder) GetStatus() *gomock.Call {
 	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetStatus", reflect.TypeOf((*MockAPI)(nil).GetStatus))
 }
 
+// GetThresholds mocks base method.
+func (m *MockAPI) GetThresholds() ThresholdsConfig {
+	m.ctrl.T.Helper()
+	ret := m.ctrl.Call(m, "GetThresholds")
+	ret0, _ := ret[0].(ThresholdsConfig)
+	return ret0
+}
+
+// GetThresholds indicates an expected call of GetThresholds.
+func (mr *MockAPIMockRecorder) GetThresholds() *gomock.Call {
+	mr.mock.ctrl.T.Helper()
+	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetThresholds", reflect.TypeOf((*MockAPI)(nil).GetThresholds))
+}
+
+// GetTitle mocks base method.
+func (m *MockAPI) GetTitle() string {
+	m.ctrl.T.Helper()
+	ret := m.ctrl.Call(m, "GetTitle")
+	ret0, _ := ret[0].(string)
+	return ret0
+}
+
+// GetTitle indicates an expected call of GetTitle.
+func (mr *MockAPIMockRecorder) GetTitle() *gomock.Call {
+	mr.mock.ctrl.T.Helper()
+	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetTitle", reflect.TypeOf((*MockAPI)(nil).GetTitle))
+}
+
 // GetVehicle mocks base method.
 func (m *MockAPI) GetVehicle() api.Vehicle {
 	m.ctrl.T.Helper()
@@ -544,6 +656,18 @@ func (mr *MockAPIMockRecorder) SetBatteryBoost(enable any) *gomock.Call {
 	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SetBatteryBoost", reflect.TypeOf((*MockAPI)(nil).SetBatteryBoost), enable)
 }
 
+// SetDefaultMode mocks base method.
+func (m *MockAPI) SetDefaultMode(arg0 api.ChargeMode) {
+	m.ctrl.T.Helper()
+	m.ctrl.Call(m, "SetDefaultMode", arg0)
+}
+
+// SetDefaultMode indicates an expected call of SetDefaultMode.
+func (mr *MockAPIMockRecorder) SetDefaultMode(arg0 any) *gomock.Call {
+	mr.mock.ctrl.T.Helper()
+	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SetDefaultMode", reflect.TypeOf((*MockAPI)(nil).SetDefaultMode), arg0)
+}
+
 // SetDisableDelay mocks base method.
 func (m *MockAPI) SetDisableDelay(delay time.Duration) {
 	m.ctrl.T.Helper()
@@ -708,6 +832,42 @@ func (mr *MockAPIMockRecorder) SetSmartCostLimit(limit any) *gomock.Call {
 	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SetSmartCostLimit", reflect.TypeOf((*MockAPI)(nil).SetSmartCostLimit), limit)
 }
 
+// SetSocConfig mocks base method.
+func (m *MockAPI) SetSocConfig(soc SocConfig) {
+	m.ctrl.T.Helper()
+	m.ctrl.Call(m, "SetSocConfig", soc)
+}
+
+// SetSocConfig indicates an expected call of SetSocConfig.
+func (mr *MockAPIMockRecorder) SetSocConfig(soc any) *gomock.Call {
+	mr.mock.ctrl.T.Helper()
+	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SetSocConfig", reflect.TypeOf((*MockAPI)(nil).SetSocConfig), soc)
+}
+
+// SetThresholds mocks base method.
+func (m *MockAPI) SetThresholds(thresholds ThresholdsConfig) {
+	m.ctrl.T.Helper()
+	m.ctrl.Call(m, "SetThresholds", thresholds)
+}
+
+// SetThresholds indicates an expected call of SetThresholds.
+func (mr *MockAPIMockRecorder) SetThresholds(thresholds any) *gomock.Call {
+	mr.mock.ctrl.T.Helper()
+	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SetThresholds", reflect.TypeOf((*MockAPI)(nil).SetThresholds), thresholds)
+}
+
+// SetTitle mocks base method.
+func (m *MockAPI) SetTitle(arg0 string) {
+	m.ctrl.T.Helper()
+	m.ctrl.Call(m, "SetTitle", arg0)
+}
+
+// SetTitle indicates an expected call of SetTitle.
+func (mr *MockAPIMockRecorder) SetTitle(arg0 any) *gomock.Call {
+	mr.mock.ctrl.T.Helper()
+	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SetTitle", reflect.TypeOf((*MockAPI)(nil).SetTitle), arg0)
+}
+
 // SetVehicle mocks base method.
 func (m *MockAPI) SetVehicle(vehicle api.Vehicle) {
 	m.ctrl.T.Helper()
@@ -745,17 +905,3 @@ func (mr *MockAPIMockRecorder) StartVehicleDetection() *gomock.Call {
 	mr.mock.ctrl.T.Helper()
 	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "StartVehicleDetection", reflect.TypeOf((*MockAPI)(nil).StartVehicleDetection))
 }
-
-// Title mocks base method.
-func (m *MockAPI) Title() string {
-	m.ctrl.T.Helper()
-	ret := m.ctrl.Call(m, "Title")
-	ret0, _ := ret[0].(string)
-	return ret0
-}
-
-// Title indicates an expected call of Title.
-func (mr *MockAPIMockRecorder) Title() *gomock.Call {
-	mr.mock.ctrl.T.Helper()
-	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Title", reflect.TypeOf((*MockAPI)(nil).Title))
-}
diff --git a/core/loadpoint/pollmode_enumer.go b/core/loadpoint/pollmode_enumer.go
new file mode 100644
index 00000000..6c3b3086
--- /dev/null
+++ b/core/loadpoint/pollmode_enumer.go
@@ -0,0 +1,94 @@
+// Code generated by "enumer -type PollMode -trimprefix Poll -transform=lower -text"; DO NOT EDIT.
+
+package loadpoint
+
+import (
+	"fmt"
+	"strings"
+)
+
+const _PollModeName = "chargingconnectedalways"
+
+var _PollModeIndex = [...]uint8{0, 8, 17, 23}
+
+const _PollModeLowerName = "chargingconnectedalways"
+
+func (i PollMode) String() string {
+	if i < 0 || i >= PollMode(len(_PollModeIndex)-1) {
+		return fmt.Sprintf("PollMode(%d)", i)
+	}
+	return _PollModeName[_PollModeIndex[i]:_PollModeIndex[i+1]]
+}
+
+// An "invalid array index" compiler error signifies that the constant values have changed.
+// Re-run the stringer command to generate them again.
+func _PollModeNoOp() {
+	var x [1]struct{}
+	_ = x[PollCharging-(0)]
+	_ = x[PollConnected-(1)]
+	_ = x[PollAlways-(2)]
+}
+
+var _PollModeValues = []PollMode{PollCharging, PollConnected, PollAlways}
+
+var _PollModeNameToValueMap = map[string]PollMode{
+	_PollModeName[0:8]:        PollCharging,
+	_PollModeLowerName[0:8]:   PollCharging,
+	_PollModeName[8:17]:       PollConnected,
+	_PollModeLowerName[8:17]:  PollConnected,
+	_PollModeName[17:23]:      PollAlways,
+	_PollModeLowerName[17:23]: PollAlways,
+}
+
+var _PollModeNames = []string{
+	_PollModeName[0:8],
+	_PollModeName[8:17],
+	_PollModeName[17:23],
+}
+
+// PollModeString retrieves an enum value from the enum constants string name.
+// Throws an error if the param is not part of the enum.
+func PollModeString(s string) (PollMode, error) {
+	if val, ok := _PollModeNameToValueMap[s]; ok {
+		return val, nil
+	}
+
+	if val, ok := _PollModeNameToValueMap[strings.ToLower(s)]; ok {
+		return val, nil
+	}
+	return 0, fmt.Errorf("%s does not belong to PollMode values", s)
+}
+
+// PollModeValues returns all values of the enum
+func PollModeValues() []PollMode {
+	return _PollModeValues
+}
+
+// PollModeStrings returns a slice of all String values of the enum
+func PollModeStrings() []string {
+	strs := make([]string, len(_PollModeNames))
+	copy(strs, _PollModeNames)
+	return strs
+}
+
+// IsAPollMode returns "true" if the value is listed in the enum definition. "false" otherwise
+func (i PollMode) IsAPollMode() bool {
+	for _, v := range _PollModeValues {
+		if i == v {
+			return true
+		}
+	}
+	return false
+}
+
+// MarshalText implements the encoding.TextMarshaler interface for PollMode
+func (i PollMode) MarshalText() ([]byte, error) {
+	return []byte(i.String()), nil
+}
+
+// UnmarshalText implements the encoding.TextUnmarshaler interface for PollMode
+func (i *PollMode) UnmarshalText(text []byte) error {
+	var err error
+	*i, err = PollModeString(string(text))
+	return err
+}
diff --git a/core/loadpoint/types.go b/core/loadpoint/types.go
new file mode 100644
index 00000000..2e7b32b3
--- /dev/null
+++ b/core/loadpoint/types.go
@@ -0,0 +1,39 @@
+package loadpoint
+
+import (
+	"time"
+)
+
+// ThresholdsConfig defines pv mode hysteresis parameters
+type ThresholdsConfig struct {
+	Enable  ThresholdConfig `json:"enable"`
+	Disable ThresholdConfig `json:"disable"`
+}
+
+// ThresholdConfig defines enable/disable hysteresis parameters
+type ThresholdConfig struct {
+	Delay     time.Duration `json:"delay"`
+	Threshold float64       `json:"threshold"`
+}
+
+// SocConfig defines soc settings, estimation and update behavior
+type SocConfig struct {
+	Poll     PollConfig `json:"poll"`
+	Estimate *bool      `json:"estimate"`
+}
+
+// PollConfig defines the vehicle polling mode and interval
+type PollConfig struct {
+	Mode     PollMode      `json:"mode"`     // polling mode charging (default), connected, always
+	Interval time.Duration `json:"interval"` // interval when not charging
+}
+
+//go:generate enumer -type PollMode -trimprefix Poll -transform=lower -text
+type PollMode int
+
+// Poll modes
+const (
+	PollCharging PollMode = iota
+	PollConnected
+	PollAlways
+)
diff --git a/core/loadpoint_api.go b/core/loadpoint_api.go
index a37429df..f9158bbb 100644
--- a/core/loadpoint_api.go
+++ b/core/loadpoint_api.go
@@ -13,11 +13,50 @@ import (
 
 var _ loadpoint.API = (*Loadpoint)(nil)
 
-// Title returns the human-readable loadpoint title
-func (lp *Loadpoint) Title() string {
+// GetCharger returns the loadpoint charger
+func (lp *Loadpoint) GetChargerName() string {
+	return lp.ChargerRef
+}
+
+// GetMeter returns the loadpoint meter
+func (lp *Loadpoint) GetMeterName() string {
+	return lp.MeterRef
+}
+
+// GetCircuitName returns the loadpoint circuit
+func (lp *Loadpoint) GetCircuitName() string {
+	return lp.CircuitRef
+}
+
+// GetDefaultVehicle returns the loadpoint default vehicle
+func (lp *Loadpoint) GetDefaultVehicle() string {
+	return lp.VehicleRef
+}
+
+// GetTitle returns the loadpoint title
+func (lp *Loadpoint) GetTitle() string {
 	lp.RLock()
 	defer lp.RUnlock()
-	return lp.Title_
+	return lp.title
+}
+
+// SetTitle sets the loadpoint title
+func (lp *Loadpoint) SetTitle(title string) {
+	lp.Lock()
+	defer lp.Unlock()
+
+	lp.log.DEBUG.Println("set title:", title)
+
+	if title != lp.title {
+		lp.setTitle(title)
+	}
+}
+
+// setTitle sets the loadpoint title (no mutex)
+func (lp *Loadpoint) setTitle(title string) {
+	lp.title = title
+	lp.publish(keys.Title, lp.title)
+	lp.settings.SetString(keys.Title, lp.title)
 }
 
 // GetStatus returns the charging status
@@ -74,6 +113,26 @@ func (lp *Loadpoint) SetMode(mode api.ChargeMode) {
 	}
 }
 
+// GetDefaultMode returns the default charge mode
+func (lp *Loadpoint) GetDefaultMode() api.ChargeMode {
+	lp.RLock()
+	defer lp.RUnlock()
+	return lp.DefaultMode
+}
+
+// SetDefaultMode sets the default charge mode
+func (lp *Loadpoint) SetDefaultMode(mode api.ChargeMode) {
+	lp.Lock()
+	defer lp.Unlock()
+
+	lp.log.DEBUG.Println("set default mode:", mode)
+
+	if lp.DefaultMode != mode {
+		lp.DefaultMode = mode
+		lp.settings.SetString(keys.DefaultMode, string(mode))
+	}
+}
+
 // getChargedEnergy returns session charge energy in Wh
 func (lp *Loadpoint) getChargedEnergy() float64 {
 	lp.RLock()
@@ -85,7 +144,14 @@ func (lp *Loadpoint) getChargedEnergy() float64 {
 func (lp *Loadpoint) GetPriority() int {
 	lp.RLock()
 	defer lp.RUnlock()
-	return lp.Priority_
+	return lp.priority
+}
+
+// setPriority sets the loadpoint priority (no mutex)
+func (lp *Loadpoint) setPriority(prio int) {
+	lp.priority = prio
+	lp.publish(keys.Priority, lp.priority)
+	lp.settings.SetInt(keys.Priority, int64(lp.priority))
 }
 
 // SetPriority sets the loadpoint priority
@@ -96,8 +162,7 @@ func (lp *Loadpoint) SetPriority(prio int) {
 	lp.log.DEBUG.Println("set priority:", prio)
 
 	if lp.Priority_ != prio {
-		lp.Priority_ = prio
-		lp.publish(keys.Priority, prio)
+		lp.setPriority(prio)
 	}
 }
 
@@ -112,7 +177,7 @@ func (lp *Loadpoint) GetPhases() int {
 func (lp *Loadpoint) SetPhases(phases int) error {
 	// limit auto mode (phases=0) to scalable charger
 	if !lp.hasPhaseSwitching() && phases == 0 {
-		return fmt.Errorf("invalid number of phases: %d", phases)
+		return fmt.Errorf("charger does not support phase switching")
 	}
 
 	if phases != 0 && phases != 1 && phases != 3 {
@@ -239,6 +304,60 @@ func (lp *Loadpoint) SetPlanEnergy(finishAt time.Time, energy float64) error {
 	return nil
 }
 
+// GetSoc returns the PV mode threshold settings
+func (lp *Loadpoint) GetSocConfig() loadpoint.SocConfig {
+	lp.RLock()
+	defer lp.RUnlock()
+	return lp.Soc
+}
+
+func (lp *Loadpoint) setSocConfig(soc loadpoint.SocConfig) {
+	lp.Soc = soc
+	lp.settings.SetJson(keys.Soc, soc)
+	lp.requestUpdate()
+}
+
+// SetSoc sets the PV mode threshold settings
+func (lp *Loadpoint) SetSocConfig(soc loadpoint.SocConfig) {
+	lp.Lock()
+	defer lp.Unlock()
+
+	lp.log.DEBUG.Printf("set soc config: %+v", soc)
+
+	// apply immediately
+	lp.setSocConfig(soc)
+}
+
+// GetThresholds returns the PV mode threshold settings
+func (lp *Loadpoint) GetThresholds() loadpoint.ThresholdsConfig {
+	lp.RLock()
+	defer lp.RUnlock()
+	return loadpoint.ThresholdsConfig{
+		Enable:  lp.Enable,
+		Disable: lp.Disable,
+	}
+}
+
+func (lp *Loadpoint) setThresholds(thresholds loadpoint.ThresholdsConfig) {
+	lp.Enable = thresholds.Enable
+	lp.Disable = thresholds.Disable
+	lp.publish(keys.EnableThreshold, lp.Enable.Threshold)
+	lp.publish(keys.DisableThreshold, lp.Disable.Threshold)
+	lp.settings.SetJson(keys.Thresholds, thresholds)
+	lp.requestUpdate()
+}
+
+// SetThresholds sets the PV mode threshold settings
+func (lp *Loadpoint) SetThresholds(thresholds loadpoint.ThresholdsConfig) {
+	lp.Lock()
+	defer lp.Unlock()
+
+	lp.log.DEBUG.Printf("set thresholds: %+v", thresholds)
+
+	// apply immediately
+	lp.setThresholds(thresholds)
+}
+
 // GetEnableThreshold gets the loadpoint enable threshold
 func (lp *Loadpoint) GetEnableThreshold() float64 {
 	lp.RLock()
@@ -255,7 +374,11 @@ func (lp *Loadpoint) SetEnableThreshold(threshold float64) {
 
 	if lp.Enable.Threshold != threshold {
 		lp.Enable.Threshold = threshold
-		lp.publish(keys.EnableThreshold, threshold)
+		// TODO reduce APIs
+		lp.setThresholds(loadpoint.ThresholdsConfig{
+			Enable:  lp.Enable,
+			Disable: lp.Disable,
+		})
 	}
 }
 
@@ -275,7 +398,11 @@ func (lp *Loadpoint) SetDisableThreshold(threshold float64) {
 
 	if lp.Disable.Threshold != threshold {
 		lp.Disable.Threshold = threshold
-		lp.publish(keys.DisableThreshold, threshold)
+		// TODO reduce APIs
+		lp.setThresholds(loadpoint.ThresholdsConfig{
+			Enable:  lp.Enable,
+			Disable: lp.Disable,
+		})
 	}
 }
 
diff --git a/core/loadpoint_phases.go b/core/loadpoint_phases.go
index fc8d3f46..ead79581 100644
--- a/core/loadpoint_phases.go
+++ b/core/loadpoint_phases.go
@@ -8,8 +8,9 @@ import (
 // setConfiguredPhases sets the default phase configuration
 func (lp *Loadpoint) setConfiguredPhases(phases int) {
 	lp.configuredPhases = phases
-	lp.publish(keys.PhasesConfigured, lp.configuredPhases)
-	lp.settings.SetInt(keys.PhasesConfigured, int64(lp.configuredPhases))
+	lp.publish(keys.Phases, lp.configuredPhases)
+	lp.publish(keys.PhasesConfigured, lp.configuredPhases) // TODO remove
+	lp.settings.SetInt(keys.Phases, int64(lp.configuredPhases))
 }
 
 // setPhases sets the number of enabled phases without modifying the charger
diff --git a/core/loadpoint_vehicle.go b/core/loadpoint_vehicle.go
index e7022d47..d4c2df0d 100644
--- a/core/loadpoint_vehicle.go
+++ b/core/loadpoint_vehicle.go
@@ -314,9 +314,9 @@ func (lp *Loadpoint) vehicleOdometer() {
 // vehicleClimatePollAllowed determines if polling depending on mode and connection status
 func (lp *Loadpoint) vehicleClimatePollAllowed() bool {
 	switch {
-	case lp.Soc.Poll.Mode == pollCharging && lp.charging():
+	case lp.Soc.Poll.Mode == loadpoint.PollCharging && lp.charging():
 		return true
-	case (lp.Soc.Poll.Mode == pollConnected || lp.Soc.Poll.Mode == pollAlways) && lp.connected():
+	case (lp.Soc.Poll.Mode == loadpoint.PollConnected || lp.Soc.Poll.Mode == loadpoint.PollAlways) && lp.connected():
 		return true
 	default:
 		return false
@@ -337,8 +337,8 @@ func (lp *Loadpoint) vehicleSocPollAllowed() bool {
 
 	remaining := lp.Soc.Poll.Interval - lp.clock.Since(lp.socUpdated)
 
-	honourUpdateInterval := lp.Soc.Poll.Mode == pollAlways ||
-		lp.connected() && lp.Soc.Poll.Mode == pollConnected
+	honourUpdateInterval := lp.Soc.Poll.Mode == loadpoint.PollAlways ||
+		lp.connected() && lp.Soc.Poll.Mode == loadpoint.PollConnected
 
 	if honourUpdateInterval {
 		if remaining > 0 {
diff --git a/core/prioritizer/prioritizer.go b/core/prioritizer/prioritizer.go
index 39c90b8b..ff47cd5d 100644
--- a/core/prioritizer/prioritizer.go
+++ b/core/prioritizer/prioritizer.go
@@ -40,12 +40,12 @@ func (p *Prioritizer) GetChargePowerFlexibility(lp loadpoint.API) float64 {
 	for lp, power := range p.demand {
 		if lp.EffectivePriority() < prio && power > 0 {
 			reduceBy += power
-			msg += fmt.Sprintf("%.0fW from %s at prio %d, ", power, lp.Title(), lp.EffectivePriority())
+			msg += fmt.Sprintf("%.0fW from %s at prio %d, ", power, lp.GetTitle(), lp.EffectivePriority())
 		}
 	}
 
 	if p.log != nil && reduceBy > 0 {
-		p.log.DEBUG.Printf("lp %s at prio %d gets additional %stotal %.0fW\n", lp.Title(), lp.EffectivePriority(), msg, reduceBy)
+		p.log.DEBUG.Printf("lp %s at prio %d gets additional %stotal %.0fW\n", lp.GetTitle(), lp.EffectivePriority(), msg, reduceBy)
 	}
 
 	return reduceBy
diff --git a/core/settings.go b/core/settings.go
deleted file mode 100644
index a13e5043..00000000
--- a/core/settings.go
+++ /dev/null
@@ -1,107 +0,0 @@
-package core
-
-import (
-	"time"
-
-	"github.com/evcc-io/evcc/server/db/settings"
-)
-
-// type Settings interface {
-// 	SetString(val string)
-// 	SetInt(val int64)
-// 	SetFloat(val float64)
-// 	SetTime(val time.Time)
-// 	SetJson(val any) error
-// 	SetBool(val bool)
-// 	String(string, error)
-// 	Int(int64, error)
-// 	Float(float64, error)
-// 	Time(time.Time, error)
-// 	Bool(bool, error)
-// 	Json(res any) error
-// }
-
-type Settings struct {
-	Key string
-}
-
-func (s *Settings) SetString(key string, val string) {
-	if s == nil {
-		return
-	}
-	settings.SetString(s.Key+key, val)
-}
-
-func (s *Settings) SetInt(key string, val int64) {
-	if s == nil {
-		return
-	}
-	settings.SetInt(s.Key+key, val)
-}
-
-func (s *Settings) SetFloat(key string, val float64) {
-	if s == nil {
-		return
-	}
-	settings.SetFloat(s.Key+key, val)
-}
-
-func (s *Settings) SetTime(key string, val time.Time) {
-	if s == nil {
-		return
-	}
-	settings.SetTime(s.Key+key, val)
-}
-
-func (s *Settings) SetBool(key string, val bool) {
-	if s == nil {
-		return
-	}
-	settings.SetBool(s.Key+key, val)
-}
-
-// func (s *Settings) SetJson(key string, val any) error {
-// 	if s == nil {
-// 		return nil
-// 	}
-// 	return settings.SetJson(s.Key+key, val)
-// }
-
-func (s *Settings) String(key string) (string, error) {
-	if s == nil {
-		return "", nil
-	}
-	return settings.String(s.Key + key)
-}
-
-func (s *Settings) Int(key string) (int64, error) {
-	if s == nil {
-		return 0, nil
-	}
-	return settings.Int(s.Key + key)
-}
-
-func (s *Settings) Float(key string) (float64, error) {
-	if s == nil {
-		return 0, nil
-	}
-	return settings.Float(s.Key + key)
-}
-
-func (s *Settings) Time(key string) (time.Time, error) {
-	if s == nil {
-		return time.Time{}, nil
-	}
-	return settings.Time(s.Key + key)
-}
-
-func (s *Settings) Bool(key string) (bool, error) {
-	if s == nil {
-		return false, nil
-	}
-	return settings.Bool(s.Key + key)
-}
-
-// func (s *Settings) Json(key string, res any) error {
-// 	return settings.Json(s.Key+key, &res)
-// }
diff --git a/core/settings/config.go b/core/settings/config.go
new file mode 100644
index 00000000..9b7bd5ae
--- /dev/null
+++ b/core/settings/config.go
@@ -0,0 +1,111 @@
+package settings
+
+import (
+	"encoding/json"
+	"errors"
+	"time"
+
+	"github.com/evcc-io/evcc/util"
+	"github.com/evcc-io/evcc/util/config"
+	"github.com/spf13/cast"
+)
+
+var _ Settings = (*ConfigSettings)(nil)
+
+type ConfigSettings struct {
+	log  *util.Logger
+	conf *config.Config
+}
+
+func NewConfigSettingsAdapter(log *util.Logger, conf *config.Config) *ConfigSettings {
+	return &ConfigSettings{log, conf}
+}
+
+func (s *ConfigSettings) get(key string) (any, error) {
+	val := s.conf.Named().Other[key]
+	if val == nil {
+		return nil, errors.New("not found")
+	}
+	return val, nil
+}
+
+func (s *ConfigSettings) set(key string, val any) {
+	data := s.conf.Named().Other
+	data[key] = val
+	if err := s.conf.PartialUpdate(data); err != nil {
+		s.log.ERROR.Println(err)
+	}
+}
+
+func (s *ConfigSettings) SetString(key string, val string) {
+	s.set(key, val)
+}
+
+func (s *ConfigSettings) SetInt(key string, val int64) {
+	s.set(key, val)
+}
+
+func (s *ConfigSettings) SetFloat(key string, val float64) {
+	s.set(key, val)
+}
+
+func (s *ConfigSettings) SetTime(key string, val time.Time) {
+	s.set(key, val)
+}
+
+func (s *ConfigSettings) SetBool(key string, val bool) {
+	s.set(key, val)
+}
+
+func (s *ConfigSettings) SetJson(key string, val any) error {
+	s.set(key, val)
+	return nil
+}
+
+func (s *ConfigSettings) String(key string) (string, error) {
+	val, err := s.get(key)
+	if err != nil {
+		return "", err
+	}
+	return cast.ToStringE(val)
+}
+
+func (s *ConfigSettings) Int(key string) (int64, error) {
+	val, err := s.get(key)
+	if err != nil {
+		return 0, err
+	}
+	return cast.ToInt64E(val)
+}
+
+func (s *ConfigSettings) Float(key string) (float64, error) {
+	val, err := s.get(key)
+	if err != nil {
+		return 0, err
+	}
+	return cast.ToFloat64E(val)
+}
+
+func (s *ConfigSettings) Time(key string) (time.Time, error) {
+	val, err := s.get(key)
+	if err != nil {
+		return time.Time{}, err
+	}
+	return cast.ToTimeE(val)
+}
+
+func (s *ConfigSettings) Bool(key string) (bool, error) {
+	val, err := s.get(key)
+	if err != nil {
+		return false, err
+	}
+	return cast.ToBoolE(val)
+}
+
+func (s *ConfigSettings) Json(key string, res any) error {
+	str, err := s.String(key)
+	if str == "" || err != nil {
+		return err
+	}
+	return json.Unmarshal([]byte(str), &res)
+}
diff --git a/core/settings/database.go b/core/settings/database.go
new file mode 100644
index 00000000..22331c81
--- /dev/null
+++ b/core/settings/database.go
@@ -0,0 +1,65 @@
+package settings
+
+import (
+	"time"
+
+	db "github.com/evcc-io/evcc/server/db/settings"
+)
+
+var _ Settings = (*dbSettings)(nil)
+
+type dbSettings struct {
+	Key string
+}
+
+func NewDatabaseSettingsAdapter(key string) Settings {
+	return &dbSettings{key}
+}
+
+func (s *dbSettings) SetString(key string, val string) {
+	db.SetString(s.Key+key, val)
+}
+
+func (s *dbSettings) SetInt(key string, val int64) {
+	db.SetInt(s.Key+key, val)
+}
+
+func (s *dbSettings) SetFloat(key string, val float64) {
+	db.SetFloat(s.Key+key, val)
+}
+
+func (s *dbSettings) SetTime(key string, val time.Time) {
+	db.SetTime(s.Key+key, val)
+}
+
+func (s *dbSettings) SetBool(key string, val bool) {
+	db.SetBool(s.Key+key, val)
+}
+
+func (s *dbSettings) SetJson(key string, val any) error {
+	return db.SetJson(s.Key+key, val)
+}
+
+func (s *dbSettings) String(key string) (string, error) {
+	return db.String(s.Key + key)
+}
+
+func (s *dbSettings) Int(key string) (int64, error) {
+	return db.Int(s.Key + key)
+}
+
+func (s *dbSettings) Float(key string) (float64, error) {
+	return db.Float(s.Key + key)
+}
+
+func (s *dbSettings) Time(key string) (time.Time, error) {
+	return db.Time(s.Key + key)
+}
+
+func (s *dbSettings) Bool(key string) (bool, error) {
+	return db.Bool(s.Key + key)
+}
+
+func (s *dbSettings) Json(key string, res any) error {
+	return db.Json(s.Key+key, &res)
+}
diff --git a/core/settings/settings.go b/core/settings/settings.go
new file mode 100644
index 00000000..a7701e65
--- /dev/null
+++ b/core/settings/settings.go
@@ -0,0 +1,18 @@
+package settings
+
+import "time"
+
+type Settings interface {
+	SetString(key string, val string)
+	SetInt(key string, val int64)
+	SetFloat(key string, val float64)
+	SetTime(key string, val time.Time)
+	SetJson(key string, val any) error
+	SetBool(key string, val bool)
+	String(key string) (string, error)
+	Int(key string) (int64, error)
+	Float(key string) (float64, error)
+	Time(key string) (time.Time, error)
+	Bool(key string) (bool, error)
+	Json(key string, res any) error
+}
diff --git a/core/site.go b/core/site.go
index 3ed034f1..0fa446da 100644
--- a/core/site.go
+++ b/core/site.go
@@ -162,7 +162,7 @@ func (site *Site) Boot(log *util.Logger, loadpoints []*Loadpoint, tariffs *tarif
 
 		if db.Instance != nil {
 			var err error
-			if lp.db, err = session.NewStore(lp.Title(), db.Instance); err != nil {
+			if lp.db, err = session.NewStore(lp.GetTitle(), db.Instance); err != nil {
 				return err
 			}
 			// Fix any dangling history
diff --git a/hems/semp/semp.go b/hems/semp/semp.go
index 5445a605..fdd30d2a 100644
--- a/hems/semp/semp.go
+++ b/hems/semp/semp.go
@@ -375,7 +375,7 @@ func (s *SEMP) deviceInfo(id int, lp loadpoint.API) DeviceInfo {
 	res := DeviceInfo{
 		Identification: Identification{
 			DeviceID:     s.deviceID(id),
-			DeviceName:   lp.Title(),
+			DeviceName:   lp.GetTitle(),
 			DeviceType:   sempCharger,
 			DeviceSerial: s.serialNumber(id),
 			DeviceVendor: "github.com/evcc-io/evcc",
diff --git a/i18n/de.toml b/i18n/de.toml
index 7b614764..1de15248 100644
--- a/i18n/de.toml
+++ b/i18n/de.toml
@@ -27,6 +27,20 @@ never = "nur mit genug PV-Überschuss."
 titleAdd = "Batterie hinzufügen"
 titleEdit = "Batterie bearbeiten"
 
+[config.charge]
+titleAdd = "Energiezähler hinzufügen"
+titleEdit = "Energiezähler bearbeiten"
+
+[config.charger]
+chargers = "Wallboxen"
+generic = "Generische Integrationen"
+ocppHelp = "Kopiere diese Adresse in die Konfiguration der Wallbox."
+ocppLabel = "OCPP-Server URL"
+switchsockets = "Schaltbare Steckdosen"
+template = "Hersteller"
+titleAdd = "Wallbox hinzufügen"
+titleEdit = "Wallbox bearbeiten"
+
 [config.circuits]
 description = "Stellt sicher, dass die Summe aller Ladepunkte, die an einen Stromkreis angeschlossen sind, die konfigurierten Leistungs- und Stromgrenzen nicht überschreitet. Stromkreise können verschachtelt werden, um eine Hierarchie aufzubauen."
 title = "Lastmanagement"
@@ -48,8 +62,6 @@ chargeStatus = "Status"
 chargeStatusA = "nicht verbunden"
 chargeStatusB = "verbunden"
 chargeStatusC = "lädt"
-chargeStatusE = "keine Leistung"
-chargeStatusF = "Fehler"
 chargedEnergy = "Geladen"
 co2 = "Netz-CO₂"
 configured = "Konfiguriert"
@@ -68,9 +80,11 @@ org = "Organisation"
 phaseCurrents = "Strom L1, L2, L3"
 phasePowers = "Leistung L1, L2, L3"
 phaseVoltages = "Spannung L1, L2, L3"
+phases1p3p = "Phasenumschaltung"
 power = "Leistung"
 powerRange = "Leistung"
 range = "Reichweite"
+singlePhase = "Einphasig"
 soc = "SoC"
 socLimit = "SoC Begrenzung"
 temp = "Temperatur"
@@ -78,6 +92,11 @@ topic = "Thema"
 url = "URL"
 yes = "Ja"
 
+[config.deviceValueChargeStatus]
+A = "A (nicht verbunden)"
+B = "B (verbunden)"
+C = "C (lädt)"
+
 [config.eebus]
 description = "Grundkonfiguration für die Kommunikation mit anderen EEBus-Geräten."
 title = "EEBus"
@@ -88,18 +107,20 @@ optional = "optional"
 
 [config.general]
 cancel = "Abbrechen"
+delete = "Löschen"
 docsLink = "Siehe Dokumentation."
 experimental = "Experimentell"
-hideAdvancedSettings = "Erweiterte einstellungen ausblenden"
+hideAdvancedSettings = "Erweiterte Einstellungen ausblenden"
 off = "aus"
 on = "an"
 password = "Passwort"
 readFromFile = "Aus Datei lesen"
 remove = "Entfernen"
 save = "Speichern"
-showAdvancedSettings = "Erweiterte einstellungen anzeigen"
+showAdvancedSettings = "Erweiterte Einstellungen anzeigen"
 telemetry = "Telemetrie"
 title = "Titel"
+validateSave = "Überprüfen & speichern"
 
 [config.grid]
 title = "Netzzähler"
@@ -126,9 +147,84 @@ title = "InfluxDB"
 v1Support = "Unterstützung für InfluxDB 1.x?"
 v2Support = "Zurück zu InfluxDB 2.x"
 
+[config.loadpoint]
+addCharger = "Wallbox hinzufügen"
+addMeter = "Zusätzlichen Zähler hinzufügen"
+cancel = "Abbrechen"
+chargerLabel = "Wallbox"
+chargerPower11kw = "11 kW"
+chargerPower11kwHelp = "Verwendet einen Strombereich von 6 bis 16 A."
+chargerPower22kw = "22 kW"
+chargerPower22kwHelp = "Verwendet einen Strombereich von 6 bis 32 A."
+chargerPowerCustom = "andere"
+chargerPowerCustomHelp = "Definiere einen eigenen Strombereich."
+chargerTypeLabel = "Ladeleistung"
+chargingTitle = "Laden"
+circuitLabel = "Stromkreis"
+circuitUnassigned = "nicht zugewiesen"
+defaultModeHelp = "Lademodus beim Anschließen des Fahrzeugs."
+defaultModeHelpKeep = "Der letzte ausgewählte Modus wird beibehalten."
+defaultModeLabel = "Standard-Modus"
+delete = "Löschen"
+electricalSubtitle = "ggf. Elektriker fragen"
+electricalTitle = "Elektrisch"
+energyMeterHelp = "Zusätzlicher Zähler, wenn die Wallbox keinen integrierten hat."
+energyMeterLabel = "Energiezähler"
+estimateLabel = "Ladestand zwischen API-Updates interpolieren"
+maxCurrentHelp = "Muss größer als der minimale Strom sein."
+maxCurrentLabel = "Maximaler Strom"
+minCurrentHelp = "Gehe nur unter 6 A, wenn du weißt, was du tust."
+minCurrentLabel = "Minimaler Strom"
+noVehicles = "Keine Fahrzeuge konfiguriert."
+phases1p = "1-phasig"
+phases3p = "3-phasig"
+phasesAutomatic = "Automatische Phasen"
+phasesAutomaticHelp = "Deine Wallbox unterstützt automatisches Umschalten zwischen 1- und 3-phasigem Laden. In der Hauptansicht kannst du das Phasenverhalten während des Ladens anpassen."
+phasesHelp = "Anzahl der an die Wallbox angeschlossenen Phasen."
+phasesLabel = "Phasen"
+pollIntervalHelp = "Zeit zwischen Fahrzeug-API-Updates. Kurze Intervalle können die Fahrzeugbatterie belasten."
+pollIntervalLabel = "Update-Intervall"
+pollModeAlways = "immer"
+pollModeAlwaysHelp = "Statusabfragen immer in regelmäßigen Abständen durchführen."
+pollModeCharging = "nur beim Laden"
+pollModeChargingHelp = "Fahrzeugstatus nur während des Ladens abfragen."
+pollModeConnected = "wenn verbunden"
+pollModeConnectedHelp = "Fahrzeugstatus in regelmäßigen Abständen abfragen, wenn verbunden."
+pollModeLabel = "Update-Verhalten"
+priorityHelp = "Ladepunkte mit höherer Priorität haben Vorrang beim PV-Überschuss."
+priorityLabel = "Priorität"
+save = "Speichern"
+showAllSettings = "Alle Einstellungen anzeigen"
+solarBehaviorCustomHelp = "Definiere eigene Ein- und Ausschaltschwellen und Verzögerungen."
+solarBehaviorDefaultHelp = "Nur mit PV-Überschuss laden. Start nach {enableDelay} Überschuss. Stop wenn für {disableDelay} nicht genug Überschuss vorhanden ist."
+solarBehaviorLabel = "PV-Verhalten"
+solarModeCustom = "eigenes"
+solarModeMaximum = "nur Sonne"
+thresholdDisableDelayLabel = "Ausschaltverzögerung"
+thresholdDisableHelpInvalid = "Bitte verwende einen positiven Wert."
+thresholdDisableHelpPositive = "Laden stoppen, wenn mehr als {power} für {delay} aus dem Netz bezogen wird."
+thresholdDisableHelpZero = "Stoppen, wenn minimale Ladeleistung für {delay} nicht erreicht werden kann."
+thresholdDisableLabel = "Netzleistung ausschalten"
+thresholdEnableDelayLabel = "Einschaltverzögerung"
+thresholdEnableHelpInvalid = "Bitte verwende einen negativen Wert."
+thresholdEnableHelpNegative = "Laden starten, wenn {surplus} Überschuss für {delay} verfügbar ist."
+thresholdEnableHelpZero = "Starten, wenn minimale Ladeleistung für {delay} als Überschuss verfügbar ist."
+thresholdEnableLabel = "Netzleistung einschalten"
+titleAdd = "Ladepunkt hinzufügen"
+titleEdit = "Ladepunkt bearbeiten"
+titleExample = "Garage, Carport, etc."
+titleLabel = "Titel"
+vehicleAutoDetection = "automatische Erkennung"
+vehicleHelpAutoDetection = "Wählt automatisch das plausibelste Fahrzeug aus. Manuelle Übersteuerung möglich."
+vehicleHelpDefault = "Nimmt immer an, dass dieses Fahrzeug hier lädt. Auto-Erkennung deaktiviert. Manuelle Übersteuerung möglich."
+vehicleLabel = "Standard-Fahrzeug"
+vehiclesTitle = "Fahrzeuge"
+
 [config.main]
+addGrid = "Netzzähler hinzufügen"
 addLoadpoint = "Ladepunkt hinzufügen"
 addPvBattery = "PV oder Speicher hinzufügen"
+addTariffs = "Tarife hinzufügen"
 addVehicle = "Fahrzeug hinzufügen"
 configured = "konfiguriert"
 edit = "bearbeiten"
@@ -195,6 +291,11 @@ little = "little-endian"
 http = "HTTP (unverschlüsselt)"
 https = "HTTPS (verschlüsselt)"
 
+[config.options.status]
+A = "A (nicht verbunden)"
+B = "B (verbunden)"
+C = "C (lädt)"
+
 [config.pv]
 titleAdd = "Zähler (PV) hinzufügen"
 titleEdit = "Zähler (PV) bearbeiten"
@@ -209,13 +310,16 @@ system = "System"
 vehicles = "Fahrzeuge"
 
 [config.sponsor]
-addToken = "Sponsortoken eingeben"
-changeToken = "Sponsortoken ändern"
+addToken = "Token eingeben"
+changeToken = "Token ändern"
 description = "Das Sponsoring-Modell hilft uns, das Projekt zu pflegen und nachhaltig neue und spannende Funktionen zu entwickeln. Als Sponsor erhältst du Zugriff auf alle Wallbox-Implementierungen."
 descriptionToken = "Du erhältst das Token von {url}. Wir bieten auch ein Testtoken für Tests an."
 error = "Das Sponsortoken ist ungültig."
 labelToken = "Sponsortoken"
 title = "Sponsorship"
+tokenRequired = "Du musst ein Sponsortoken konfigurieren, bevor du dieses Gerät anlegen kannst."
+tokenRequiredLearnMore = "Mehr erfahren."
+trialToken = "Testtoken"
 
 [config.system]
 logs = "Logs"
@@ -293,7 +397,7 @@ thisYear = "dieses Jahr"
 total = "gesamt"
 
 [footer.sponsor]
-becomeSponsor = "Werden Sie Sponsor"
+becomeSponsor = "Werde Sponsor"
 becomeSponsorExtended = "Unterstütze uns direkt. Es gibt auch Sticker."
 confetti = "Lust auf Konfetti?"
 confettiPromise = "Es gibt auch Sticker und digitales Konfetti"
@@ -381,7 +485,9 @@ reset = "Passwort zurücksetzen?"
 title = "Authentifizierung"
 
 [main]
+startConfiguration = "Konfiguration beginnen"
 vehicles = "Parkplatz"
+welcome = "Herzlich willkommen!"
 
 [main.chargingPlan]
 active = "Aktiv"
@@ -629,16 +735,6 @@ csvTotal = "Gesamte CSV herunterladen"
 date = "Anfang"
 downloadCsv = "Als CSV herunterladen"
 energy = "Geladen"
-energyGroupedTitle = "Energiemenge {energy}"
-energyGroupedTitleByGroup = "Energiemenge {byGroup}"
-energySubTitle = "{energy} gesamt"
-energyTitle = "{percent} Sonnenenergie"
-groupedCo2TitleByGroup = "CO₂-Menge {byGroup}"
-groupedPriceTitleByGroup = "Kosten {byGroup}"
-historyCo2SubTitle = "{value} gesamt"
-historyCo2Title = "⌀ {value} CO₂-Emissionen"
-historyPriceSubTitle = "{value} gesamt"
-historyPriceTitle = "⌀ {value} Ladepreis"
 loadpoint = "Ladepunkt"
 noData = "Noch keine Ladevorgänge in diesem Monat."
 overview = "Übersicht"
diff --git a/i18n/en.toml b/i18n/en.toml
index 26ea338b..ab62c2e7 100644
--- a/i18n/en.toml
+++ b/i18n/en.toml
@@ -27,6 +27,20 @@ never = "only with enough surplus."
 titleAdd = "Add Battery"
 titleEdit = "Edit Battery"
 
+[config.charge]
+titleAdd = "Add Charge Meter"
+titleEdit = "Edit Charge Meter"
+
+[config.charger]
+chargers = "EV chargers"
+generic = "Generic integrations"
+ocppHelp = "Copy this address into your chargers configuration."
+ocppLabel = "OCPP-Server URL"
+switchsockets = "Switchable sockets"
+template = "Manufacturer"
+titleAdd = "Add Charger"
+titleEdit = "Edit Charger"
+
 [config.circuits]
 description = "Ensures, that the sum of all loadpoints connected to a circuit does not exceed the configured power and current limits. Circuits can be nested to build a hierarchy."
 title = "Load Management"
@@ -62,15 +76,18 @@ energy = "Energy"
 feedinPrice = "Feed-in price"
 gridPrice = "Grid price"
 hemsType = "System"
+identifier = "RFID-Identifier"
 no = "no"
 odometer = "Odometer"
 org = "Organization"
 phaseCurrents = "Current L1, L2, L3"
 phasePowers = "Power L1, L2, L3"
 phaseVoltages = "Voltage L1, L2, L3"
+phases1p3p = "Phase switch"
 power = "Power"
 powerRange = "Power"
 range = "Range"
+singlePhase = "Single phase"
 soc = "SoC"
 socLimit = "Limit"
 temp = "Temperature"
@@ -78,6 +95,11 @@ topic = "Topic"
 url = "URL"
 yes = "yes"
 
+[config.deviceValueChargeStatus]
+A = "A (not connected)"
+B = "B (connected)"
+C = "C (charging)"
+
 [config.eebus]
 description = "Configuration that enables evcc to communicate with other EEBus devices."
 title = "EEBus"
@@ -88,6 +110,7 @@ optional = "optional"
 
 [config.general]
 cancel = "Cancel"
+delete = "Delete"
 docsLink = "See documentation."
 experimental = "Experimental"
 hideAdvancedSettings = "Hide advanced settings"
@@ -100,6 +123,7 @@ save = "Save"
 showAdvancedSettings = "Show advanced settings"
 telemetry = "Telemetry"
 title = "Title"
+validateSave = "Validate & save"
 
 [config.grid]
 title = "Grid meter"
@@ -126,9 +150,84 @@ title = "InfluxDB"
 v1Support = "Need support for InfluxDB 1.x?"
 v2Support = "Back to InfluxDB 2.x"
 
+[config.loadpoint]
+addCharger = "Add charger"
+addMeter = "Add dedicated charger meter"
+cancel = "Cancel"
+chargerLabel = "Charger"
+chargerPower11kw = "11 kW"
+chargerPower11kwHelp = "Will use a current range of 6 to 16 A."
+chargerPower22kw = "22 kW"
+chargerPower22kwHelp = "Will use a current range of 6 to 32 A."
+chargerPowerCustom = "other"
+chargerPowerCustomHelp = "Define a custom current range."
+chargerTypeLabel = "Charger type"
+chargingTitle = "Charging"
+circuitLabel = "Circuit"
+circuitUnassigned = "unassigned"
+defaultModeHelp = "Charging mode when connecting the vehicle."
+defaultModeHelpKeep = "Keeps the last selected charging mode."
+defaultModeLabel = "Default mode"
+delete = "Delete"
+electricalSubtitle = "When in doubt, ask your electrician."
+electricalTitle = "Electrical"
+energyMeterHelp = "Additional meter if the charger doesn't have an integrated one."
+energyMeterLabel = "Energy meter"
+estimateLabel = "Interpolate charge level between API updates"
+maxCurrentHelp = "Must be greater than minimum current."
+maxCurrentLabel = "Maximum current"
+minCurrentHelp = "Only go below 6 A if you know what you're doing."
+minCurrentLabel = "Minimum current"
+noVehicles = "No vehicles are configured."
+phases1p = "1-phase"
+phases3p = "3-phase"
+phasesAutomatic = "Automatic phases"
+phasesAutomaticHelp = "Your charger supports automatic switching between 1- and 3-phase charging. In the main screen you can adjust phase behaviour while charging."
+phasesHelp = "Number of phases connected to the charger."
+phasesLabel = "Phases"
+pollIntervalHelp = "Time between vehicle API updates. Short intervals may drain the vehicle battery."
+pollIntervalLabel = "Update interval"
+pollModeAlways = "always"
+pollModeAlwaysHelp = "Always request status updates in regular intervals."
+pollModeCharging = "charging"
+pollModeChargingHelp = "Only request vehicle status updates when charging."
+pollModeConnected = "connected"
+pollModeConnectedHelp = "Update vehicle status in regular intervals when connected."
+pollModeLabel = "Update behaviour"
+priorityHelp = "Higher priority charge points get preferred access to solar surplus."
+priorityLabel = "Priority"
+save = "Save"
+showAllSettings = "Show all settings"
+solarBehaviorCustomHelp = "Define your own enable and disable thresholds and delays."
+solarBehaviorDefaultHelp = "Only charge with solar surplus. Start after {enableDelay} of surplus. Stop when there is not enough surplus for {disableDelay}."
+solarBehaviorLabel = "Solar behaviour"
+solarModeCustom = "custom"
+solarModeMaximum = "maximum solar"
+thresholdDisableDelayLabel = "Disable delay"
+thresholdDisableHelpInvalid = "Please use a positive value."
+thresholdDisableHelpPositive = "Stop charging, when more than {power} is used from the grid for {delay}."
+thresholdDisableHelpZero = "Stop when minimum charge power can't be satisfied for {delay}."
+thresholdDisableLabel = "Disable grid power"
+thresholdEnableDelayLabel = "Enable delay"
+thresholdEnableHelpInvalid = "Please use a negative value."
+thresholdEnableHelpNegative = "Start charging, when {surplus} surplus is available for {delay}."
+thresholdEnableHelpZero = "Start when minimum charge power surplus is available for {delay}."
+thresholdEnableLabel = "Enable grid power"
+titleAdd = "Add Charge Point"
+titleEdit = "Edit Charge Point"
+titleExample = "Garage, Carport, etc."
+titleLabel = "Title"
+vehicleAutoDetection = "auto detection"
+vehicleHelpAutoDetection = "Automatically selects the most plausible vehicle. Manual override is possible."
+vehicleHelpDefault = "Always assume this vehicle is charging here. Auto-detection disabled. Manual override is possible."
+vehicleLabel = "Default vehicle"
+vehiclesTitle = "Vehicles"
+
 [config.main]
+addGrid = "Add grid meter"
 addLoadpoint = "Add charge point"
 addPvBattery = "Add solar or battery"
+addTariffs = "Add tariffs"
 addVehicle = "Add vehicle"
 configured = "configured"
 edit = "edit"
@@ -194,6 +293,11 @@ little = "little-endian"
 http = "HTTP (unencrypted)"
 https = "HTTPS (encrypted)"
 
+[config.options.status]
+A = "A (not connected)"
+B = "B (connected)"
+C = "C (charging)"
+
 [config.pv]
 titleAdd = "Add Solar Meter"
 titleEdit = "Edit Solar Meter"
@@ -208,13 +312,16 @@ system = "System"
 vehicles = "Vehicles"
 
 [config.sponsor]
-addToken = "Enter sponsor token"
-changeToken = "Change sponsor token"
+addToken = "Enter token"
+changeToken = "Change token"
 description = "The sponsoring model helps us to maintain the project and sustainably build new and exciting features. As a sponsor you get access to all charger implementations."
 descriptionToken = "You get the token from {url}. We also offer a trial token for testing."
 error = "The sponsor token is not valid."
 labelToken = "Sponsor token"
 title = "Sponsorship"
+tokenRequired = "You must configure a sponsor token before you can create this device."
+tokenRequiredLearnMore = "Learn more."
+trialToken = "Trial token"
 
 [config.system]
 logs = "Logs"
@@ -380,7 +487,9 @@ reset = "Reset password?"
 title = "Authentication"
 
 [main]
+startConfiguration = "Let's start configuration"
 vehicles = "Parking"
+welcome = "Hello aboard!"
 
 [main.chargingPlan]
 active = "Active"
diff --git a/server/helper.go b/server/helper.go
index 43cd1206..ed6c8442 100644
--- a/server/helper.go
+++ b/server/helper.go
@@ -1,7 +1,9 @@
 package server
 
 import (
+	"encoding/json"
 	"fmt"
+	"io"
 	"math"
 	"reflect"
 	"slices"
@@ -26,6 +28,13 @@ func parseFloat(payload string) (float64, error) {
 	return f, err
 }
 
+// jsonDecoder returns a json decoder with disallowed unknown fields
+func jsonDecoder(r io.Reader) *json.Decoder {
+	dec := json.NewDecoder(r)
+	dec.DisallowUnknownFields()
+	return dec
+}
+
 // omitEmpty returns true if struct field is omitempty
 func omitEmpty(f reflect.StructField) bool {
 	tag := f.Tag.Get("json")
diff --git a/server/http.go b/server/http.go
index a6765cb6..4a87b384 100644
--- a/server/http.go
+++ b/server/http.go
@@ -156,6 +156,7 @@ func (s *HTTPd) RegisterSiteHandlers(site site.API, valueChan chan<- util.Param)
 	}
 
 	// loadpoint api
+	// TODO any loadpoint
 	for id, lp := range site.Loadpoints() {
 		api := api.PathPrefix(fmt.Sprintf("/loadpoints/%d", id+1)).Subrouter()
 
@@ -236,7 +237,7 @@ func (s *HTTPd) RegisterSystemHandler(valueChan chan<- util.Param, cache *util.P
 		routes := map[string]route{
 			"templates":          {"GET", "/templates/{class:[a-z]+}", templatesHandler},
 			"products":           {"GET", "/products/{class:[a-z]+}", productsHandler},
-			"devices":            {"GET", "/devices/{class:[a-z]+}", devicesHandler},
+			"devices":            {"GET", "/devices/{class:[a-z]+}", devicesConfigHandler},
 			"device":             {"GET", "/devices/{class:[a-z]+}/{id:[0-9.]+}", deviceConfigHandler},
 			"devicestatus":       {"GET", "/devices/{class:[a-z]+}/{name:[a-zA-Z0-9_.:-]+}/status", deviceStatusHandler},
 			"dirty":              {"GET", "/dirty", getHandler(ConfigDirty)},
@@ -247,7 +248,7 @@ func (s *HTTPd) RegisterSystemHandler(valueChan chan<- util.Param, cache *util.P
 			"testmerged":         {"POST", "/test/{class:[a-z]+}/merge/{id:[0-9.]+}", testConfigHandler},
 			"interval":           {"POST", "/interval/{value:[0-9.]+}", settingsSetDurationHandler(keys.Interval)},
 			"updatesponsortoken": {"POST", "/sponsortoken", updateSponsortokenHandler},
-			"deletesponsortoken": {"DELETE", "/sponsortoken", settingsDeleteHandler(keys.SponsorToken)},
+			"deletesponsortoken": {"DELETE", "/sponsortoken", deleteSponsorTokenHandler},
 		}
 
 		// yaml handlers
@@ -279,6 +280,17 @@ func (s *HTTPd) RegisterSystemHandler(valueChan chan<- util.Param, cache *util.P
 		for _, r := range routes {
 			api.Methods(r.Methods()...).Path(r.Pattern).Handler(r.HandlerFunc)
 		}
+
+		// loadpoints
+		for _, r := range map[string]route{
+			"loadpoints":      {"GET", "/loadpoints", loadpointsConfigHandler()},
+			"loadpoint":       {"GET", "/loadpoints/{id:[0-9.]+}", loadpointConfigHandler()},
+			"updateloadpoint": {"PUT", "/loadpoints/{id:[0-9.]+}", updateLoadpointHandler()},
+			"deleteloadpoint": {"DELETE", "/loadpoints/{id:[0-9.]+}", deleteLoadpointHandler()},
+			"newloadpoint":    {"POST", "/loadpoints", newLoadpointHandler()},
+		} {
+			api.Methods(r.Methods()...).Path(r.Pattern).Handler(r.HandlerFunc)
+		}
 	}
 
 	{ // api/system
diff --git a/server/http_config_device_handler.go b/server/http_config_device_handler.go
index 91871d4b..39474ce8 100644
--- a/server/http_config_device_handler.go
+++ b/server/http_config_device_handler.go
@@ -4,6 +4,7 @@ import (
 	"context"
 	"encoding/json"
 	"errors"
+	"fmt"
 	"net/http"
 	"strconv"
 
@@ -33,11 +34,17 @@ func devicesConfig[T any](class templates.Class, h config.Handler[T]) ([]map[str
 	return res, nil
 }
 
-// devicesHandler returns a device configurations by class
-func devicesHandler(w http.ResponseWriter, r *http.Request) {
+// devicesConfigHandler returns a device configurations by class
+func devicesConfigHandler(w http.ResponseWriter, r *http.Request) {
 	vars := mux.Vars(r)
 
 	class, err := templates.ClassString(vars["class"])
+
+	// TODO exclude loadpoints here
+	if err == nil && class == templates.Loadpoint {
+		err = fmt.Errorf("invalid class %s", class)
+	}
+
 	if err != nil {
 		jsonError(w, http.StatusBadRequest, err)
 		return
@@ -142,6 +149,8 @@ func deviceConfigHandler(w http.ResponseWriter, r *http.Request) {
 		return
 	}
 
+	// TODO return application/yaml content type if type != template
+
 	jsonResult(w, res)
 }
 
@@ -202,7 +211,7 @@ func newDevice[T any](class templates.Class, req map[string]any, newFromConf new
 		return nil, err
 	}
 
-	return &conf, h.Add(config.NewConfigurableDevice[T](conf, instance))
+	return &conf, h.Add(config.NewConfigurableDevice[T](&conf, instance))
 }
 
 // newDeviceHandler creates a new device by class
@@ -215,6 +224,8 @@ func newDeviceHandler(w http.ResponseWriter, r *http.Request) {
 		return
 	}
 
+	// TODO add application/yaml content type, reject type==template
+
 	var req map[string]any
 	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
 		jsonError(w, http.StatusBadRequest, err)
@@ -288,6 +299,8 @@ func updateDeviceHandler(w http.ResponseWriter, r *http.Request) {
 		return
 	}
 
+	// TODO add application/yaml content type, reject type==template
+
 	var req map[string]any
 	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
 		jsonError(w, http.StatusBadRequest, err)
diff --git a/server/http_config_helper.go b/server/http_config_helper.go
index 1d16f2f2..138c712d 100644
--- a/server/http_config_helper.go
+++ b/server/http_config_helper.go
@@ -188,6 +188,14 @@ func testInstance(instance any) map[string]testResult {
 		res["chargedEnergy"] = makeResult(val, err)
 	}
 
+	if _, ok := instance.(api.PhaseSwitcher); ok {
+		res["phases1p3p"] = makeResult(true, nil)
+	}
+
+	if cc, ok := instance.(api.PhaseDescriber); ok {
+		res["singlePhase"] = makeResult(cc.Phases() == 1, nil)
+	}
+
 	if dev, ok := instance.(api.VehicleRange); ok {
 		val, err := dev.Range()
 		res["range"] = makeResult(val, err)
@@ -198,5 +206,10 @@ func testInstance(instance any) map[string]testResult {
 		res["socLimit"] = makeResult(val, err)
 	}
 
+	if dev, ok := instance.(api.Identifier); ok {
+		val, err := dev.Identify()
+		res["identifier"] = makeResult(val, err)
+	}
+
 	return res
 }
diff --git a/server/http_config_loadpoint_handler.go b/server/http_config_loadpoint_handler.go
new file mode 100644
index 00000000..4896b0f1
--- /dev/null
+++ b/server/http_config_loadpoint_handler.go
@@ -0,0 +1,251 @@
+package server
+
+import (
+	"errors"
+	"io"
+	"net/http"
+	"strconv"
+
+	"github.com/evcc-io/evcc/core"
+	"github.com/evcc-io/evcc/core/loadpoint"
+	coresettings "github.com/evcc-io/evcc/core/settings"
+	"github.com/evcc-io/evcc/util"
+	"github.com/evcc-io/evcc/util/config"
+	"github.com/evcc-io/evcc/util/templates"
+	"github.com/gorilla/mux"
+	"github.com/samber/lo"
+)
+
+func getLoadpointStaticConfig(lp loadpoint.API) loadpoint.StaticConfig {
+	return loadpoint.StaticConfig{
+		Charger: lp.GetChargerName(),
+		Meter:   lp.GetMeterName(),
+		Circuit: lp.GetCircuitName(),
+		Vehicle: lp.GetDefaultVehicle(),
+	}
+}
+
+func getLoadpointDynamicConfig(lp loadpoint.API) loadpoint.DynamicConfig {
+	planTime, planEnergy := lp.GetPlanEnergy()
+	return loadpoint.DynamicConfig{
+		Title:          lp.GetTitle(),
+		DefaultMode:    string(lp.GetDefaultMode()),
+		Priority:       lp.GetPriority(),
+		Phases:         lp.GetPhases(),
+		MinCurrent:     lp.GetMinCurrent(),
+		MaxCurrent:     lp.GetMaxCurrent(),
+		SmartCostLimit: lp.GetSmartCostLimit(),
+		Thresholds:     lp.GetThresholds(),
+		Soc:            lp.GetSocConfig(),
+		PlanEnergy:     planEnergy,
+		PlanTime:       planTime,
+		LimitEnergy:    lp.GetLimitEnergy(),
+		LimitSoc:       lp.GetLimitSoc(),
+	}
+}
+
+type loadpointFullConfig struct {
+	ID   int    `json:"id,omitempty"` // db row id
+	Name string `json:"name"`         // either slice index (yaml) or db:<row id>
+
+	// static config
+	loadpoint.StaticConfig
+	loadpoint.DynamicConfig
+}
+
+func loadpointSplitConfig(r io.Reader) (loadpoint.DynamicConfig, map[string]any, error) {
+	var payload map[string]any
+
+	if err := jsonDecoder(r).Decode(&payload); err != nil {
+		return loadpoint.DynamicConfig{}, nil, err
+	}
+
+	return loadpoint.SplitConfig(payload)
+}
+
+// loadpointConfig returns a single loadpoint's configuration
+func loadpointConfig(dev config.Device[loadpoint.API]) loadpointFullConfig {
+	lp := dev.Instance()
+
+	var id int
+	if configurable, ok := dev.(config.ConfigurableDevice[loadpoint.API]); ok {
+		id = configurable.ID()
+	}
+
+	res := loadpointFullConfig{
+		ID:   id,
+		Name: dev.Config().Name,
+
+		StaticConfig:  getLoadpointStaticConfig(lp),
+		DynamicConfig: getLoadpointDynamicConfig(lp),
+	}
+
+	return res
+}
+
+// loadpointsConfigHandler returns a device configurations by class
+func loadpointsConfigHandler() http.HandlerFunc {
+	return func(w http.ResponseWriter, r *http.Request) {
+		res := lo.Map(config.Loadpoints().Devices(), func(dev config.Device[loadpoint.API], _ int) loadpointFullConfig {
+			return loadpointConfig(dev)
+		})
+
+		jsonResult(w, res)
+	}
+}
+
+// loadpointConfigHandler returns a device configurations by class
+func loadpointConfigHandler() http.HandlerFunc {
+	h := config.Loadpoints()
+
+	return func(w http.ResponseWriter, r *http.Request) {
+		vars := mux.Vars(r)
+
+		id, err := strconv.Atoi(vars["id"])
+		if err != nil {
+			jsonError(w, http.StatusBadRequest, err)
+			return
+		}
+
+		dev, err := h.ByName(config.NameForID(id))
+		if err != nil {
+			jsonError(w, http.StatusBadRequest, err)
+			return
+		}
+
+		res := loadpointConfig(dev)
+
+		jsonResult(w, res)
+	}
+}
+
+// newLoadpointHandler creates a new loadpoint
+func newLoadpointHandler() http.HandlerFunc {
+	h := config.Loadpoints()
+
+	// TODO revert charger, meter etc
+
+	return func(w http.ResponseWriter, r *http.Request) {
+		dynamic, static, err := loadpointSplitConfig(r.Body)
+		if err != nil {
+			jsonError(w, http.StatusBadRequest, err)
+			return
+		}
+
+		id := len(h.Devices())
+		name := "lp-" + strconv.Itoa(id+1)
+		log := util.NewLoggerWithLoadpoint(name, id+1)
+
+		conf, err := config.AddConfig(templates.Loadpoint, "", static)
+		if err != nil {
+			jsonError(w, http.StatusBadRequest, err)
+			return
+		}
+
+		settings := coresettings.NewConfigSettingsAdapter(log, &conf)
+		instance, err := core.NewLoadpointFromConfig(log, settings, static)
+		if err != nil {
+			conf.Delete()
+			jsonError(w, http.StatusBadRequest, err)
+			return
+		}
+
+		dev := config.NewConfigurableDevice[loadpoint.API](&conf, instance)
+		if err := dynamic.Apply(instance); err != nil {
+			conf.Delete()
+			jsonError(w, http.StatusBadRequest, err)
+			return
+		}
+
+		if err := h.Add(dev); err != nil {
+			conf.Delete()
+			jsonError(w, http.StatusBadRequest, err)
+			return
+		}
+
+		setConfigDirty()
+
+		w.WriteHeader(http.StatusOK)
+	}
+}
+
+// updateLoadpointHandler returns a device configurations by class
+func updateLoadpointHandler() http.HandlerFunc {
+	h := config.Loadpoints()
+
+	return func(w http.ResponseWriter, r *http.Request) {
+		vars := mux.Vars(r)
+
+		id, err := strconv.Atoi(vars["id"])
+		if err != nil {
+			jsonError(w, http.StatusBadRequest, err)
+			return
+		}
+
+		dev, err := h.ByName(config.NameForID(id))
+		if err != nil {
+			jsonError(w, http.StatusBadRequest, err)
+			return
+		}
+
+		configurable, ok := dev.(config.ConfigurableDevice[loadpoint.API])
+		if !ok {
+			jsonError(w, http.StatusBadRequest, errors.New("not configurable"))
+			return
+		}
+
+		instance := dev.Instance()
+
+		dynamic, static, err := loadpointSplitConfig(r.Body)
+		if err != nil {
+			jsonError(w, http.StatusBadRequest, err)
+			return
+		}
+
+		// static
+		if err := configurable.PartialUpdate(static, instance); err != nil {
+			jsonError(w, http.StatusBadRequest, err)
+			return
+		}
+
+		// dynamic
+		if err := dynamic.Apply(instance); err != nil {
+			jsonError(w, http.StatusBadRequest, err)
+			return
+		}
+
+		setConfigDirty()
+
+		w.WriteHeader(http.StatusOK)
+	}
+}
+
+// deleteLoadpointHandler deletes a loadpoint
+func deleteLoadpointHandler() http.HandlerFunc {
+	h := config.Loadpoints()
+
+	return func(w http.ResponseWriter, r *http.Request) {
+		vars := mux.Vars(r)
+
+		id, err := strconv.Atoi(vars["id"])
+		if err != nil {
+			jsonError(w, http.StatusBadRequest, err)
+			return
+		}
+
+		setConfigDirty()
+
+		if err := deleteDevice(id, h); err != nil {
+			jsonError(w, http.StatusBadRequest, err)
+			return
+		}
+
+		res := struct {
+			ID int `json:"id"`
+		}{
+			ID: id,
+		}
+
+		jsonResult(w, res)
+	}
+}
diff --git a/server/http_config_site_handler.go b/server/http_config_site_handler.go
index eb3b0a22..eb460f77 100644
--- a/server/http_config_site_handler.go
+++ b/server/http_config_site_handler.go
@@ -1,7 +1,6 @@
 package server
 
 import (
-	"encoding/json"
 	"net/http"
 
 	"github.com/evcc-io/evcc/core/site"
@@ -47,7 +46,7 @@ func updateSiteHandler(site site.API) http.HandlerFunc {
 			Battery *[]string
 		}
 
-		if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
+		if err := jsonDecoder(r.Body).Decode(&payload); err != nil {
 			jsonError(w, http.StatusBadRequest, err)
 			return
 		}
diff --git a/server/http_config_site_other_handler.go b/server/http_config_site_other_handler.go
index 6ee5a7b4..cd8ab90e 100644
--- a/server/http_config_site_other_handler.go
+++ b/server/http_config_site_other_handler.go
@@ -32,3 +32,9 @@ func updateSponsortokenHandler(w http.ResponseWriter, r *http.Request) {
 
 	jsonResult(w, sponsor.Status())
 }
+
+func deleteSponsorTokenHandler(w http.ResponseWriter, r *http.Request) {
+	settings.SetString(keys.SponsorToken, "")
+	setConfigDirty()
+	jsonResult(w, true)
+}
diff --git a/server/influxdb.go b/server/influxdb.go
index 0e331ecc..fb395161 100644
--- a/server/influxdb.go
+++ b/server/influxdb.go
@@ -165,7 +165,7 @@ func (m *Influx) Run(site site.API, in <-chan util.Param) {
 		if param.Loadpoint != nil {
 			lp := site.Loadpoints()[*param.Loadpoint]
 
-			tags["loadpoint"] = lp.Title()
+			tags["loadpoint"] = lp.GetTitle()
 			if v := lp.GetVehicle(); v != nil {
 				tags["vehicle"] = v.Title()
 			}
diff --git a/server/mqtt.go b/server/mqtt.go
index fae7a971..3e0fd735 100644
--- a/server/mqtt.go
+++ b/server/mqtt.go
@@ -203,6 +203,7 @@ func (m *MQTT) listenLoadpointSetters(topic string, site site.API, lp loadpoint.
 		{"mode", setterFunc(api.ChargeModeString, pass(lp.SetMode))},
 		{"phases", intSetter(lp.SetPhases)},
 		{"limitSoc", intSetter(pass(lp.SetLimitSoc))},
+		{"priority", intSetter(pass(lp.SetPriority))},
 		{"minCurrent", floatSetter(lp.SetMinCurrent)},
 		{"maxCurrent", floatSetter(lp.SetMaxCurrent)},
 		{"limitEnergy", floatSetter(pass(lp.SetLimitEnergy))},
diff --git a/templates/definition/charger/demo-charger.yaml b/templates/definition/charger/demo-charger.yaml
index 9ced9183..03da11b2 100644
--- a/templates/definition/charger/demo-charger.yaml
+++ b/templates/definition/charger/demo-charger.yaml
@@ -14,7 +14,7 @@ params:
       de: Ladezustand
       en: Charge status
     type: choice
-    choice: [A, B, C, D, E, F]
+    choice: [A, B, C]
     default: A
   - name: power
     description:
@@ -36,6 +36,13 @@ params:
     example: 16
     type: int
     advanced: true
+  - name: phases1p3p
+    description:
+      de: Phasenumschaltung
+      en: Phase switching
+    type: bool
+    default: false
+    advanced: true
 
 render: |
   type: custom
@@ -55,3 +62,10 @@ render: |
   power:
     source: const
     value: {{ .power }}
+  {{ if eq .phases1p3p "true" }}
+  phases1p3p:
+    source: js
+    script: |
+      3
+  tos: true
+  {{ end }}
diff --git a/util/config/config.go b/util/config/config.go
index ad472f8b..870ee153 100644
--- a/util/config/config.go
+++ b/util/config/config.go
@@ -4,6 +4,7 @@ import (
 	"encoding/json"
 	"fmt"
 
+	"dario.cat/mergo"
 	"github.com/evcc-io/evcc/util/templates"
 	"gorm.io/gorm"
 )
@@ -74,6 +75,29 @@ func (d *Config) Update(conf map[string]any) error {
 	})
 }
 
+// PartialUpdate partially updates a config's details to the database
+func (d *Config) PartialUpdate(conf map[string]any) error {
+	return db.Transaction(func(tx *gorm.DB) error {
+		var config Config
+		if err := tx.Where(Config{Class: d.Class, ID: d.ID}).First(&config).Error; err != nil {
+			return err
+		}
+
+		actual := d.detailsAsMap()
+		if err := mergo.Merge(&actual, conf, mergo.WithOverride); err != nil {
+			return err
+		}
+
+		val, err := detailsFromMap(actual)
+		if err != nil {
+			return err
+		}
+		d.Value = val
+
+		return tx.Save(&d).Error
+	})
+}
+
 // Delete deletes a config from the database
 func (d *Config) Delete() error {
 	return db.Delete(Config{ID: d.ID}).Error
diff --git a/util/config/device.go b/util/config/device.go
index a8a18f38..0c1ec1f1 100644
--- a/util/config/device.go
+++ b/util/config/device.go
@@ -7,16 +7,19 @@ type Device[T any] interface {
 type ConfigurableDevice[T any] interface {
 	Device[T]
 	ID() int
+	// Assign(T)
+	// Update1(map[string]any) error
 	Update(map[string]any, T) error
+	PartialUpdate(map[string]any, T) error
 	Delete() error
 }
 
 type configurableDevice[T any] struct {
-	config   Config
+	config   *Config
 	instance T
 }
 
-func NewConfigurableDevice[T any](config Config, instance T) ConfigurableDevice[T] {
+func NewConfigurableDevice[T any](config *Config, instance T) ConfigurableDevice[T] {
 	return &configurableDevice[T]{
 		config:   config,
 		instance: instance,
@@ -35,6 +38,14 @@ func (d *configurableDevice[T]) ID() int {
 	return d.config.ID
 }
 
+// func (d *configurableDevice[T]) Assign(instance T) {
+// 	d.instance = instance
+// }
+
+//	func (d *configurableDevice[T]) Update(config map[string]any) error {
+//		return d.config.Update(config)
+//	}
+
 func (d *configurableDevice[T]) Update(config map[string]any, instance T) error {
 	if err := d.config.Update(config); err != nil {
 		return err
@@ -43,6 +54,14 @@ func (d *configurableDevice[T]) Update(config map[string]any, instance T) error
 	return nil
 }
 
+func (d *configurableDevice[T]) PartialUpdate(config map[string]any, instance T) error {
+	if err := d.config.PartialUpdate(config); err != nil {
+		return err
+	}
+	d.instance = instance
+	return nil
+}
+
 func (d *configurableDevice[T]) Delete() error {
 	return d.config.Delete()
 }
diff --git a/util/config/instance.go b/util/config/instance.go
index d6fbee1b..a68b0a2b 100644
--- a/util/config/instance.go
+++ b/util/config/instance.go
@@ -3,15 +3,17 @@ package config
 import (
 	evbus "github.com/asaskevich/EventBus"
 	"github.com/evcc-io/evcc/api"
+	"github.com/evcc-io/evcc/core/loadpoint"
 )
 
 var bus = evbus.New()
 
 var instance struct {
-	meters   *handler[api.Meter]
-	chargers *handler[api.Charger]
-	vehicles *handler[api.Vehicle]
-	circuits *handler[api.Circuit]
+	meters     *handler[api.Meter]
+	chargers   *handler[api.Charger]
+	vehicles   *handler[api.Vehicle]
+	circuits   *handler[api.Circuit]
+	loadpoints *handler[loadpoint.API]
 }
 
 func init() {
@@ -23,6 +25,7 @@ func Reset() {
 	instance.chargers = &handler[api.Charger]{topic: "charger"}
 	instance.vehicles = &handler[api.Vehicle]{topic: "vehicle"}
 	instance.circuits = &handler[api.Circuit]{topic: "circuit"}
+	instance.loadpoints = &handler[loadpoint.API]{topic: "loadpoint"}
 }
 
 type Handler[T any] interface {
@@ -49,6 +52,10 @@ func Circuits() Handler[api.Circuit] {
 	return instance.circuits
 }
 
+func Loadpoints() Handler[loadpoint.API] {
+	return instance.loadpoints
+}
+
 // Instances returns the instances of the given devices
 func Instances[T any](devices []Device[T]) []T {
 	res := make([]T, 0, len(devices))
diff --git a/util/templates/class.go b/util/templates/class.go
index 42ea0c10..14a3a65d 100644
--- a/util/templates/class.go
+++ b/util/templates/class.go
@@ -9,5 +9,6 @@ const (
 	Meter
 	Vehicle
 	Tariff
+	Loadpoint
 	Circuit
 )
diff --git a/util/templates/class_enumer.go b/util/templates/class_enumer.go
index 48acc8c8..bcbf9a0b 100644
--- a/util/templates/class_enumer.go
+++ b/util/templates/class_enumer.go
@@ -7,11 +7,11 @@ import (
 	"strings"
 )
 
-const _ClassName = "chargermetervehicletariffcircuit"
+const _ClassName = "chargermetervehicletariffloadpointcircuit"
 
-var _ClassIndex = [...]uint8{0, 7, 12, 19, 25, 32}
+var _ClassIndex = [...]uint8{0, 7, 12, 19, 25, 34, 41}
 
-const _ClassLowerName = "chargermetervehicletariffcircuit"
+const _ClassLowerName = "chargermetervehicletariffloadpointcircuit"
 
 func (i Class) String() string {
 	i -= 1
@@ -29,10 +29,11 @@ func _ClassNoOp() {
 	_ = x[Meter-(2)]
 	_ = x[Vehicle-(3)]
 	_ = x[Tariff-(4)]
-	_ = x[Circuit-(5)]
+	_ = x[Loadpoint-(5)]
+	_ = x[Circuit-(6)]
 }
 
-var _ClassValues = []Class{Charger, Meter, Vehicle, Tariff, Circuit}
+var _ClassValues = []Class{Charger, Meter, Vehicle, Tariff, Loadpoint, Circuit}
 
 var _ClassNameToValueMap = map[string]Class{
 	_ClassName[0:7]:        Charger,
@@ -43,8 +44,10 @@ var _ClassNameToValueMap = map[string]Class{
 	_ClassLowerName[12:19]: Vehicle,
 	_ClassName[19:25]:      Tariff,
 	_ClassLowerName[19:25]: Tariff,
-	_ClassName[25:32]:      Circuit,
-	_ClassLowerName[25:32]: Circuit,
+	_ClassName[25:34]:      Loadpoint,
+	_ClassLowerName[25:34]: Loadpoint,
+	_ClassName[34:41]:      Circuit,
+	_ClassLowerName[34:41]: Circuit,
 }
 
 var _ClassNames = []string{
@@ -52,7 +55,8 @@ var _ClassNames = []string{
 	_ClassName[7:12],
 	_ClassName[12:19],
 	_ClassName[19:25],
-	_ClassName[25:32],
+	_ClassName[25:34],
+	_ClassName[34:41],
 }
 
 // ClassString retrieves an enum value from the enum constants string name.
diff --git a/vehicle/embed.go b/vehicle/embed.go
index b9416cb7..e4259490 100644
--- a/vehicle/embed.go
+++ b/vehicle/embed.go
@@ -40,6 +40,8 @@ func (v *embed) Capacity() float64 {
 	return v.Capacity_
 }
 
+var _ api.PhaseDescriber = (*embed)(nil)
+
 // Phases returns the phases used by the vehicle
 func (v *embed) Phases() int {
 	return v.Phases_


Test patch:
diff --git a/assets/js/components/Config/TestResult.vue b/assets/js/components/Config/TestResult.vue
index 5a6f1308..d34a3b15 100644
--- a/assets/js/components/Config/TestResult.vue
+++ b/assets/js/components/Config/TestResult.vue
@@ -18,7 +18,7 @@
 				role="status"
 				aria-hidden="true"
 			></span>
-			<a v-else href="#" class="alert-link" @click.prevent="$emit('test')">
+			<a v-else href="#" class="alert-link" tabindex="0" @click.prevent="$emit('test')">
 				{{ $t("config.validation.validate") }}
 			</a>
 		</div>
diff --git a/charger/template_test.go b/charger/template_test.go
index 9b0e6c38..884d0b00 100644
--- a/charger/template_test.go
+++ b/charger/template_test.go
@@ -29,7 +29,7 @@ var acceptable = []string{
 	"(Client.Timeout exceeded while awaiting headers)",
 	"can only have either uri or device",                                   // modbus
 	"connection already registered with different protocol: localhost:502", // modbus
-	"sponsorship required, see https://github.com/evcc-io/evcc#sponsorship",
+	"sponsorship required, see https://docs.evcc.io/docs/sponsorship",
 	"eebus not configured",
 	"context deadline exceeded",
 	"timeout",                    // ocpp
diff --git a/cmd/setup_circuits_test.go b/cmd/setup_circuits_test.go
index 80f65190..5728ace6 100644
--- a/cmd/setup_circuits_test.go
+++ b/cmd/setup_circuits_test.go
@@ -6,6 +6,7 @@ import (
 
 	"github.com/evcc-io/evcc/api"
 	"github.com/evcc-io/evcc/api/globalconfig"
+	"github.com/evcc-io/evcc/core"
 	"github.com/evcc-io/evcc/server/db"
 	"github.com/evcc-io/evcc/util/config"
 	"github.com/stretchr/testify/suite"
@@ -59,9 +60,11 @@ loadpoints:
 		Name: "test",
 	}, api.Charger(nil))))
 
-	lps, err := configureLoadpoints(conf)
+	err := configureLoadpoints(conf)
 	suite.Require().NoError(err)
-	suite.Require().NotNil(lps[0].GetCircuit())
+
+	lps := config.Loadpoints().Devices()
+	suite.Require().NotNil(lps[0].Instance().GetCircuit())
 }
 
 func (suite *circuitsTestSuite) TestCircuitMissingLoadpoint() {
@@ -88,9 +91,15 @@ loadpoints:
 		Name: "test",
 	}, api.Charger(nil))))
 
-	lps, err := configureLoadpoints(conf)
+	err := configureLoadpoints(conf)
 	suite.Require().NoError(err)
 
+	lpsd := config.Loadpoints().Devices()
+	lps := make([]*core.Loadpoint, 0, len(lpsd))
+	for _, lp := range lpsd {
+		lps = append(lps, lp.Instance().(*core.Loadpoint))
+	}
+
 	// circuit without device
 	err = validateCircuits(lps)
 	suite.Require().Error(err)
@@ -140,9 +149,15 @@ loadpoints:
 		Name: "test",
 	}, api.Charger(nil))))
 
-	lps, err := configureLoadpoints(conf)
+	err := configureLoadpoints(conf)
 	suite.Require().NoError(err)
 
+	lpsd := config.Loadpoints().Devices()
+	lps := make([]*core.Loadpoint, 0, len(lpsd))
+	for _, lp := range lpsd {
+		lps = append(lps, lp.Instance().(*core.Loadpoint))
+	}
+
 	// lp using root circuit is valid
 	suite.Require().NoError(validateCircuits(lps))
 }
diff --git a/cmd/setup_test.go b/cmd/setup_test.go
index f4dae37f..1847a8dd 100644
--- a/cmd/setup_test.go
+++ b/cmd/setup_test.go
@@ -24,11 +24,11 @@ func TestYamlOff(t *testing.T) {
 	}
 
 	var lp core.Loadpoint
-	if err := util.DecodeOther(conf.Loadpoints[0], &lp); err != nil {
+	if err := util.DecodeOther(conf.Loadpoints[0].Other, &lp); err != nil {
 		t.Error(err)
 	}
 
-	if lp.Mode_ != api.ModeOff {
-		t.Errorf("expected `off`, got %s", lp.Mode_)
+	if lp.DefaultMode != api.ModeOff {
+		t.Errorf("expected `off`, got %s", lp.DefaultMode)
 	}
 }
diff --git a/core/loadpoint_phases_test.go b/core/loadpoint_phases_test.go
index 2faca534..4dbb6b02 100644
--- a/core/loadpoint_phases_test.go
+++ b/core/loadpoint_phases_test.go
@@ -8,6 +8,7 @@ import (
 	evbus "github.com/asaskevich/EventBus"
 	"github.com/benbjohnson/clock"
 	"github.com/evcc-io/evcc/api"
+	"github.com/evcc-io/evcc/core/loadpoint"
 	"github.com/evcc-io/evcc/util"
 	"github.com/stretchr/testify/require"
 	"go.uber.org/mock/gomock"
@@ -405,10 +406,10 @@ func TestPvScalePhasesTimer(t *testing.T) {
 			phases:         tc.phases,
 			measuredPhases: tc.measuredPhases,
 			status:         api.StatusC,
-			Enable: ThresholdConfig{
+			Enable: loadpoint.ThresholdConfig{
 				Delay: dt,
 			},
-			Disable: ThresholdConfig{
+			Disable: loadpoint.ThresholdConfig{
 				Delay: dt,
 			},
 		}
diff --git a/core/loadpoint_test.go b/core/loadpoint_test.go
index c9d2d759..76b26ca5 100644
--- a/core/loadpoint_test.go
+++ b/core/loadpoint_test.go
@@ -7,6 +7,8 @@ import (
 	evbus "github.com/asaskevich/EventBus"
 	"github.com/benbjohnson/clock"
 	"github.com/evcc-io/evcc/api"
+	"github.com/evcc-io/evcc/core/loadpoint"
+	"github.com/evcc-io/evcc/core/settings"
 	"github.com/evcc-io/evcc/core/soc"
 	"github.com/evcc-io/evcc/push"
 	"github.com/evcc-io/evcc/util"
@@ -151,14 +153,14 @@ func TestUpdatePowerZero(t *testing.T) {
 	for _, tc := range tc {
 		t.Log(tc)
 
-		clck := clock.NewMock()
+		clock := clock.NewMock()
 		ctrl := gomock.NewController(t)
 		charger := api.NewMockCharger(ctrl)
 
 		lp := &Loadpoint{
 			log:         util.NewLogger("foo"),
 			bus:         evbus.New(),
-			clock:       clck,
+			clock:       clock,
 			charger:     charger,
 			chargeMeter: &Null{}, // silence nil panics
 			chargeRater: &Null{}, // silence nil panics
@@ -302,24 +304,24 @@ func TestPVHysteresis(t *testing.T) {
 		for _, tc := range tc {
 			t.Log(tc)
 
-			clck := clock.NewMock()
+			clock := clock.NewMock()
 			ctrl := gomock.NewController(t)
 			charger := api.NewMockCharger(ctrl)
 
 			Voltage = 100
 			lp := &Loadpoint{
 				log:            util.NewLogger("foo"),
-				clock:          clck,
+				clock:          clock,
 				charger:        charger,
 				minCurrent:     minA,
 				maxCurrent:     maxA,
 				phases:         phases,
 				measuredPhases: phases,
-				Enable: ThresholdConfig{
+				Enable: loadpoint.ThresholdConfig{
 					Threshold: tc.enable,
 					Delay:     dt,
 				},
-				Disable: ThresholdConfig{
+				Disable: loadpoint.ThresholdConfig{
 					Threshold: tc.disable,
 					Delay:     dt,
 				},
@@ -328,10 +330,10 @@ func TestPVHysteresis(t *testing.T) {
 			// charging, otherwise PV mode logic is short-circuited
 			lp.status = status
 
-			start := clck.Now()
+			start := clock.Now()
 
 			for step, se := range tc.series {
-				clck.Set(start.Add(se.delay))
+				clock.Set(start.Add(se.delay))
 
 				// maxCurrent will read actual current and enabled state in PV mode
 				// charger.EXPECT().Enabled().Return(tc.enabled, nil)
@@ -352,13 +354,13 @@ func TestPVHysteresis(t *testing.T) {
 func TestPVHysteresisForStatusOtherThanC(t *testing.T) {
 	const phases = 3
 
-	clck := clock.NewMock()
+	clock := clock.NewMock()
 	ctrl := gomock.NewController(t)
 
 	Voltage = 100
 	lp := &Loadpoint{
 		log:            util.NewLogger("foo"),
-		clock:          clck,
+		clock:          clock,
 		minCurrent:     minA,
 		maxCurrent:     maxA,
 		phases:         phases,
@@ -407,9 +409,9 @@ func TestDisableAndEnableAtTargetSoc(t *testing.T) {
 		socEstimator: socEstimator, // instead of vehicle: vehicle,
 		mode:         api.ModeNow,
 		limitSoc:     90, // session limit
-		Soc: SocConfig{
-			Poll: PollConfig{
-				Mode:     pollConnected, // allow polling when connected
+		Soc: loadpoint.SocConfig{
+			Poll: loadpoint.PollConfig{
+				Mode:     loadpoint.PollConnected, // allow polling when connected
 				Interval: pollInterval,
 			},
 		},
@@ -473,6 +475,7 @@ func TestSetModeAndSocAtDisconnect(t *testing.T) {
 		log:         util.NewLogger("foo"),
 		bus:         evbus.New(),
 		clock:       clock,
+		settings:    settings.NewDatabaseSettingsAdapter("foo"),
 		charger:     charger,
 		chargeMeter: &Null{}, // silence nil panics
 		chargeRater: &Null{}, // silence nil panics
@@ -481,7 +484,7 @@ func TestSetModeAndSocAtDisconnect(t *testing.T) {
 		minCurrent:  minA,
 		maxCurrent:  maxA,
 		status:      api.StatusC,
-		Mode_:       api.ModeOff, // default mode
+		DefaultMode: api.ModeOff, // default mode
 	}
 
 	attachListeners(t, lp)
@@ -616,59 +619,59 @@ func TestSocPoll(t *testing.T) {
 	lp := &Loadpoint{
 		clock: clock,
 		log:   util.NewLogger("foo"),
-		Soc: SocConfig{
-			Poll: PollConfig{
+		Soc: loadpoint.SocConfig{
+			Poll: loadpoint.PollConfig{
 				Interval: time.Hour,
 			},
 		},
 	}
 
 	tc := []struct {
-		mode   string
+		mode   loadpoint.PollMode
 		status api.ChargeStatus
 		dt     time.Duration
 		res    bool
 	}{
 		// pollCharging
-		{pollCharging, api.StatusA, -1, false},
-		{pollCharging, api.StatusA, 0, false},
-		{pollCharging, api.StatusA, tRefresh, false},
-		{pollCharging, api.StatusB, -1, true}, // poll once when car connected
-		{pollCharging, api.StatusB, 0, false},
-		{pollCharging, api.StatusB, tRefresh, false},
-		{pollCharging, api.StatusC, -1, true},
-		{pollCharging, api.StatusC, 0, true},
-		{pollCharging, api.StatusC, tNoRefresh, true}, // cached by vehicle
-		{pollCharging, api.StatusB, -1, true},         // fetch if car stopped charging
-		{pollCharging, api.StatusB, 0, false},         // no more polling
-		{pollCharging, api.StatusB, tRefresh, false},  // no more polling
+		{loadpoint.PollCharging, api.StatusA, -1, false},
+		{loadpoint.PollCharging, api.StatusA, 0, false},
+		{loadpoint.PollCharging, api.StatusA, tRefresh, false},
+		{loadpoint.PollCharging, api.StatusB, -1, true}, // poll once when car connected
+		{loadpoint.PollCharging, api.StatusB, 0, false},
+		{loadpoint.PollCharging, api.StatusB, tRefresh, false},
+		{loadpoint.PollCharging, api.StatusC, -1, true},
+		{loadpoint.PollCharging, api.StatusC, 0, true},
+		{loadpoint.PollCharging, api.StatusC, tNoRefresh, true}, // cached by vehicle
+		{loadpoint.PollCharging, api.StatusB, -1, true},         // fetch if car stopped charging
+		{loadpoint.PollCharging, api.StatusB, 0, false},         // no more polling
+		{loadpoint.PollCharging, api.StatusB, tRefresh, false},  // no more polling
 
 		// pollConnected
-		{pollConnected, api.StatusA, -1, false},
-		{pollConnected, api.StatusA, 0, false},
-		{pollConnected, api.StatusA, tRefresh, false},
-		{pollConnected, api.StatusB, -1, true},
-		{pollConnected, api.StatusB, 0, false},
-		{pollConnected, api.StatusB, tNoRefresh, false},
-		{pollConnected, api.StatusB, tRefresh, true},
-		{pollConnected, api.StatusC, -1, true},
-		{pollConnected, api.StatusC, 0, true},
-		{pollConnected, api.StatusC, tNoRefresh, true}, // cached by vehicle
-		{pollConnected, api.StatusC, tRefresh, true},
+		{loadpoint.PollConnected, api.StatusA, -1, false},
+		{loadpoint.PollConnected, api.StatusA, 0, false},
+		{loadpoint.PollConnected, api.StatusA, tRefresh, false},
+		{loadpoint.PollConnected, api.StatusB, -1, true},
+		{loadpoint.PollConnected, api.StatusB, 0, false},
+		{loadpoint.PollConnected, api.StatusB, tNoRefresh, false},
+		{loadpoint.PollConnected, api.StatusB, tRefresh, true},
+		{loadpoint.PollConnected, api.StatusC, -1, true},
+		{loadpoint.PollConnected, api.StatusC, 0, true},
+		{loadpoint.PollConnected, api.StatusC, tNoRefresh, true}, // cached by vehicle
+		{loadpoint.PollConnected, api.StatusC, tRefresh, true},
 
 		// pollAlways
-		{pollAlways, api.StatusA, -1, true},
-		{pollAlways, api.StatusA, 0, false},
-		{pollAlways, api.StatusA, tNoRefresh, false},
-		{pollAlways, api.StatusA, tRefresh, true},
-		{pollAlways, api.StatusB, -1, true},
-		{pollAlways, api.StatusB, 0, false},
-		{pollAlways, api.StatusB, tNoRefresh, false},
-		{pollAlways, api.StatusB, tRefresh, true},
-		{pollAlways, api.StatusC, -1, true},
-		{pollAlways, api.StatusC, 0, true},
-		{pollAlways, api.StatusC, tNoRefresh, true}, // cached by vehicle
-		{pollAlways, api.StatusC, tRefresh, true},
+		{loadpoint.PollAlways, api.StatusA, -1, true},
+		{loadpoint.PollAlways, api.StatusA, 0, false},
+		{loadpoint.PollAlways, api.StatusA, tNoRefresh, false},
+		{loadpoint.PollAlways, api.StatusA, tRefresh, true},
+		{loadpoint.PollAlways, api.StatusB, -1, true},
+		{loadpoint.PollAlways, api.StatusB, 0, false},
+		{loadpoint.PollAlways, api.StatusB, tNoRefresh, false},
+		{loadpoint.PollAlways, api.StatusB, tRefresh, true},
+		{loadpoint.PollAlways, api.StatusC, -1, true},
+		{loadpoint.PollAlways, api.StatusC, 0, true},
+		{loadpoint.PollAlways, api.StatusC, tNoRefresh, true}, // cached by vehicle
+		{loadpoint.PollAlways, api.StatusC, tRefresh, true},
 	}
 
 	for _, tc := range tc {
@@ -723,7 +726,7 @@ func TestPVHysteresisAfterPhaseSwitch(t *testing.T) {
 	for _, tc := range tc {
 		t.Log(tc)
 
-		clck := clock.NewMock()
+		clock := clock.NewMock()
 		ctrl := gomock.NewController(t)
 
 		switcher := api.NewMockPhaseSwitcher(ctrl)
@@ -739,21 +742,21 @@ func TestPVHysteresisAfterPhaseSwitch(t *testing.T) {
 		Voltage = 100
 		lp := &Loadpoint{
 			log:        util.NewLogger("foo"),
-			clock:      clck,
+			clock:      clock,
 			charger:    charger,
 			minCurrent: minA,
 			maxCurrent: maxA,
-			Disable: ThresholdConfig{
+			Disable: loadpoint.ThresholdConfig{
 				Delay: dt,
 			},
 			status:  api.StatusC,
 			enabled: true,
 		}
 
-		start := clck.Now()
+		start := clock.Now()
 
 		for step, se := range tc.series {
-			clck.Set(start.Add(se.delay))
+			clock.Set(start.Add(se.delay))
 			assert.Equal(t, se.current, lp.pvMaxCurrent(api.ModePV, se.site, 0, false, false), step)
 		}
 
diff --git a/core/loadpoint_vehicle_test.go b/core/loadpoint_vehicle_test.go
index ebe3bbb2..4c68145e 100644
--- a/core/loadpoint_vehicle_test.go
+++ b/core/loadpoint_vehicle_test.go
@@ -9,6 +9,7 @@ import (
 	"github.com/benbjohnson/clock"
 	"github.com/evcc-io/evcc/api"
 	"github.com/evcc-io/evcc/core/coordinator"
+	"github.com/evcc-io/evcc/core/settings"
 	"github.com/evcc-io/evcc/core/soc"
 	"github.com/evcc-io/evcc/util"
 	"github.com/stretchr/testify/assert"
@@ -171,8 +172,8 @@ func TestDefaultVehicle(t *testing.T) {
 	vehicle.EXPECT().Phases().AnyTimes()
 	vehicle.EXPECT().OnIdentified().AnyTimes()
 
-	lp := NewLoadpoint(util.NewLogger("foo"), nil)
-	lp.Mode_ = api.ModeOff // ondisconnect
+	lp := NewLoadpoint(util.NewLogger("foo"), settings.NewDatabaseSettingsAdapter("foo"))
+	lp.DefaultMode = api.ModeOff // ondisconnect
 	lp.defaultVehicle = dflt
 
 	// populate channels
diff --git a/core/prioritizer/prioritizer_test.go b/core/prioritizer/prioritizer_test.go
index 870b3aba..bd36e49d 100644
--- a/core/prioritizer/prioritizer_test.go
+++ b/core/prioritizer/prioritizer_test.go
@@ -14,12 +14,12 @@ func TestPrioritzer(t *testing.T) {
 	p := New(nil)
 
 	lo := loadpoint.NewMockAPI(ctrl)
-	lo.EXPECT().Title().AnyTimes()
+	lo.EXPECT().GetTitle().AnyTimes()
 	lo.EXPECT().GetPriority().Return(0).AnyTimes()       // prio 0
 	lo.EXPECT().EffectivePriority().Return(0).AnyTimes() // prio 0
 
 	hi := loadpoint.NewMockAPI(ctrl)
-	hi.EXPECT().Title().AnyTimes()
+	hi.EXPECT().GetTitle().AnyTimes()
 	hi.EXPECT().GetPriority().Return(1).AnyTimes()       // prio 1
 	hi.EXPECT().EffectivePriority().Return(1).AnyTimes() // prio 1
 
diff --git a/tests/config-battery.spec.js b/tests/config-battery.spec.js
index 9cb90ee6..99d442d7 100644
--- a/tests/config-battery.spec.js
+++ b/tests/config-battery.spec.js
@@ -41,13 +41,16 @@ test.describe("battery meter", async () => {
     await expect(meterModal.getByTestId("device-tag-soc")).toContainText("75.0%");
     await expect(meterModal.getByTestId("device-tag-power")).toContainText("-2.5 kW");
     await meterModal.getByRole("button", { name: "Save" }).click();
+    await expect(meterModal).not.toBeVisible();
     await expect(page.getByTestId("battery")).toBeVisible(1);
     await expect(page.getByTestId("battery")).toContainText("openems");
 
     // edit #1
     await page.getByTestId("battery").getByRole("button", { name: "edit" }).click();
+    await expect(meterModal).toBeVisible();
     await meterModal.getByLabel("Battery capacity in kWh").fill("20");
     await meterModal.getByRole("button", { name: "Validate & save" }).click();
+    await expect(meterModal).not.toBeVisible();
 
     const battery = page.getByTestId("battery");
     await expect(battery).toBeVisible(1);
diff --git a/tests/config-empty.evcc.yaml b/tests/config-empty.evcc.yaml
index c7b0b651..e69de29b 100755
--- a/tests/config-empty.evcc.yaml
+++ b/tests/config-empty.evcc.yaml
@@ -1,24 +0,0 @@
-site:
-  title: Hello World
-
-loadpoints:
-  - title: Carport
-    charger: charger
-
-chargers:
-  - name: charger
-    type: custom
-    enable:
-      source: js
-      script:
-    enabled:
-      source: js
-      script: |
-        false
-    status:
-      source: js
-      script: |
-        "B"
-    maxcurrent:
-      source: js
-      script:
diff --git a/tests/config-grid.spec.js b/tests/config-grid.spec.js
index d10fc352..6b420796 100644
--- a/tests/config-grid.spec.js
+++ b/tests/config-grid.spec.js
@@ -3,13 +3,13 @@ import { start, stop, restart, baseUrl } from "./evcc";
 import { startSimulator, stopSimulator, simulatorUrl, simulatorHost } from "./simulator";
 import { enableExperimental } from "./utils";
 
-const CONFIG_EMPTY = "config-empty.evcc.yaml";
+const CONFIG_ONE_LP = "config-one-lp.evcc.yaml";
 
 test.use({ baseURL: baseUrl() });
 
 test.beforeAll(async () => {
   await startSimulator();
-  await start(CONFIG_EMPTY);
+  await start(CONFIG_ONE_LP);
 });
 test.afterAll(async () => {
   await stop();
@@ -34,11 +34,11 @@ test.describe("grid meter", async () => {
     await page.goto("/#/config");
     await enableExperimental(page);
 
-    await expect(page.getByTestId("grid")).toHaveCount(1);
-    await expect(page.getByTestId("grid").getByTestId("device-tag-configured")).toContainText("no");
+    await expect(page.getByTestId("grid")).toHaveCount(0);
+    await expect(page.getByTestId("add-grid")).toBeVisible();
 
     // create #1
-    await page.getByTestId("grid").getByRole("button", { name: "edit" }).click();
+    await page.getByTestId("add-grid").click();
 
     const meterModal = page.getByTestId("meter-modal");
     await meterModal.getByLabel("Manufacturer").selectOption("OpenEMS");
@@ -50,7 +50,7 @@ test.describe("grid meter", async () => {
     await expect(meterModal).not.toBeVisible();
 
     // restart
-    await restart(CONFIG_EMPTY);
+    await restart(CONFIG_ONE_LP);
     await expect(page.getByTestId("grid").getByTestId("device-tag-power")).toContainText("5.0 kW");
 
     // check in main ui
@@ -64,7 +64,7 @@ test.describe("grid meter", async () => {
     await meterModal.getByRole("button", { name: "Delete" }).click();
     await expect(meterModal).not.toBeVisible();
 
-    await expect(page.getByTestId("grid")).toHaveCount(1);
-    await expect(page.getByTestId("grid").getByTestId("device-tag-configured")).toContainText("no");
+    await expect(page.getByTestId("grid")).not.toBeVisible();
+    await expect(page.getByTestId("add-grid")).toBeVisible();
   });
 });
diff --git a/tests/config-loadpoint.spec.js b/tests/config-loadpoint.spec.js
new file mode 100644
index 00000000..cb8285e3
--- /dev/null
+++ b/tests/config-loadpoint.spec.js
@@ -0,0 +1,277 @@
+import { test, expect } from "@playwright/test";
+import { start, stop, restart, baseUrl } from "./evcc";
+
+const CONFIG_EMPTY = "config-empty.evcc.yaml";
+const CONFIG_ONE_LP = "config-one-lp.evcc.yaml";
+
+test.use({ baseURL: baseUrl() });
+
+test.afterEach(async () => {
+  await stop();
+});
+
+async function enableExperimental(page) {
+  await page
+    .getByTestId("generalconfig-experimental")
+    .getByRole("button", { name: "edit" })
+    .click();
+  await page.getByLabel("Experimental 🧪").click();
+  await page.getByRole("button", { name: "Close" }).click();
+}
+
+async function addDemoCharger(page) {
+  const lpModal = page.getByTestId("loadpoint-modal");
+  await lpModal.getByRole("button", { name: "Add charger" }).click();
+
+  const modal = page.getByTestId("charger-modal");
+  await modal.getByLabel("Manufacturer").selectOption("Demo charger");
+  await modal.getByRole("button", { name: "Save" }).click();
+  await expect(modal).not.toBeVisible();
+}
+
+async function addVehicle(page, title) {
+  await page.getByRole("button", { name: "Add vehicle" }).click();
+  const modal = page.getByTestId("vehicle-modal");
+  await modal.getByLabel("Manufacturer").selectOption("Generic vehicle");
+  await modal.getByLabel("Title").fill(title);
+  await modal.getByRole("button", { name: "Validate & save" }).click();
+  await expect(modal).not.toBeVisible();
+}
+
+async function newLoadpoint(page, title) {
+  const lpModal = page.getByTestId("loadpoint-modal");
+  await page.getByRole("button", { name: "Add charge point" }).click();
+  await expect(lpModal).toBeVisible();
+  await lpModal.getByLabel("Title").fill(title);
+}
+
+test.describe("loadpoint", async () => {
+  test("create, update and delete", async ({ page }) => {
+    await start(CONFIG_EMPTY);
+
+    await page.goto("/#/config");
+    await enableExperimental(page);
+
+    const lpModal = page.getByTestId("loadpoint-modal");
+    const chargerModal = page.getByTestId("charger-modal");
+
+    await expect(page.getByTestId("loadpoint")).toHaveCount(0);
+
+    // new loadpoint
+    await newLoadpoint(page, "Solar Carport");
+    await lpModal.getByRole("button", { name: "Add charger" }).click();
+
+    // add charger
+    await chargerModal.getByLabel("Manufacturer").selectOption("Demo charger");
+    await chargerModal.getByLabel("Charge status").selectOption("C");
+    await chargerModal.getByLabel("Power").fill("11000");
+    await chargerModal.getByRole("button", { name: "Save" }).click();
+    await expect(lpModal.getByLabel("Title")).toHaveValue("Solar Carport");
+
+    // create loadpoint
+    await lpModal.getByRole("button", { name: "Save" }).click();
+    await expect(page.getByTestId("loadpoint")).toHaveCount(1);
+    await expect(page.getByTestId("loadpoint")).toContainText("Solar Carport");
+    await expect(page.getByTestId("loadpoint")).toContainText("charging");
+    await expect(page.getByTestId("loadpoint")).toContainText("11.0 kW");
+
+    // restart button appears
+    const restartButton = await page
+      .getByTestId("bottom-banner")
+      .getByRole("button", { name: "Restart" });
+    await expect(restartButton).toBeVisible();
+
+    // restart
+    await restart(CONFIG_EMPTY);
+    await page.reload();
+    await expect(page.getByTestId("loadpoint")).toHaveCount(1);
+    await expect(page.getByTestId("loadpoint")).toContainText("Solar Carport");
+
+    // update loadpoint title
+    await page.getByTestId("loadpoint").getByRole("button", { name: "edit" }).click();
+    await lpModal.getByLabel("Title").fill("Solar Carport 2");
+    await lpModal.getByRole("button", { name: "Save" }).click();
+    await expect(page.getByTestId("loadpoint")).toContainText("Solar Carport 2");
+
+    // restart
+    await restart(CONFIG_EMPTY);
+    await page.reload();
+    await expect(page.getByTestId("loadpoint")).toHaveCount(1);
+    await expect(page.getByTestId("loadpoint")).toContainText("Solar Carport 2");
+
+    // update loadpoint power
+    await page.getByTestId("loadpoint").getByRole("button", { name: "edit" }).click();
+    await lpModal.getByTestId("chargerPower-22kw").click();
+    await lpModal.getByRole("button", { name: "Save" }).click();
+    await expect(lpModal).not.toBeVisible();
+
+    // restart
+    await restart(CONFIG_EMPTY);
+    await page.reload();
+    await expect(page.getByTestId("loadpoint")).toHaveCount(1);
+    await page.getByTestId("loadpoint").getByRole("button", { name: "edit" }).click();
+    await expect(lpModal.getByTestId("chargerPower-22kw")).toHaveClass(/active/);
+    await expect(lpModal.getByLabel("Title")).toHaveValue("Solar Carport 2");
+    await lpModal.getByRole("button", { name: "Close" }).click();
+
+    // delete loadpoint
+    await page.getByTestId("loadpoint").getByRole("button", { name: "edit" }).click();
+    await lpModal.getByRole("button", { name: "Delete" }).click();
+    await expect(page.getByTestId("loadpoint")).toHaveCount(0);
+
+    // restart
+    await restart(CONFIG_EMPTY);
+    await page.reload();
+    await expect(page.getByTestId("loadpoint")).toHaveCount(0);
+  });
+
+  test("priority", async ({ page }) => {
+    await start(CONFIG_ONE_LP);
+    await page.goto("/#/config");
+    await enableExperimental(page);
+
+    const lpModal = page.getByTestId("loadpoint-modal");
+
+    // loadpoint from yaml
+    await expect(page.getByTestId("loadpoint")).toHaveCount(1);
+    await expect(page.getByTestId("loadpoint")).toContainText("Carport");
+
+    // add two more loadpoints
+    for (const title of ["Garage", "Garden"]) {
+      await newLoadpoint(page, title);
+      await addDemoCharger(page);
+      await lpModal.getByRole("button", { name: "Save" }).click();
+      await expect(lpModal).not.toBeVisible();
+    }
+
+    // three loadpoints
+    await expect(page.getByTestId("loadpoint")).toHaveCount(3);
+    await expect(page.getByTestId("loadpoint").nth(0)).toContainText("Carport");
+    await expect(page.getByTestId("loadpoint").nth(1)).toContainText("Garage");
+    await expect(page.getByTestId("loadpoint").nth(2)).toContainText("Garden");
+
+    // second loadpoint > priority 2
+    await page.getByTestId("loadpoint").nth(1).getByRole("button", { name: "edit" }).click();
+    await lpModal.getByTestId("loadpointParamPriority-2").click();
+    await lpModal.getByRole("button", { name: "Save" }).click();
+    await expect(lpModal).not.toBeVisible();
+
+    // third loadpoint > priority 1
+    await page.getByTestId("loadpoint").nth(2).getByRole("button", { name: "edit" }).click();
+    await lpModal.getByTestId("loadpointParamPriority-1").click();
+    await lpModal.getByRole("button", { name: "Save" }).click();
+    await expect(lpModal).not.toBeVisible();
+    // restart
+    await restart(CONFIG_ONE_LP);
+    await page.reload();
+
+    // check priorities
+    await page.getByTestId("loadpoint").nth(1).getByRole("button", { name: "edit" }).click();
+    await expect(lpModal.getByTestId("loadpointParamPriority-2")).toHaveClass(/active/);
+    await lpModal.getByRole("button", { name: "Close" }).click();
+
+    await page.getByTestId("loadpoint").nth(2).getByRole("button", { name: "edit" }).click();
+    await expect(lpModal.getByTestId("loadpointParamPriority-1")).toHaveClass(/active/);
+    await lpModal.getByRole("button", { name: "Close" }).click();
+  });
+
+  test("vehicle", async ({ page }) => {
+    await start(CONFIG_EMPTY);
+    await page.goto("/#/config");
+    await enableExperimental(page);
+
+    const LP_1 = "Carport";
+    const LP_2 = "Garage";
+    const VEHICLE_1 = "Green Car";
+    const VEHICLE_2 = "Yellow Van";
+
+    const lpModal = page.getByTestId("loadpoint-modal");
+
+    // add loadpoint > no vehicle option
+    await newLoadpoint(page, LP_1);
+    await addDemoCharger(page);
+    await expect(lpModal.getByLabel("Default vehicle")).not.toBeVisible();
+    await lpModal.getByRole("button", { name: "Save" }).click();
+    await expect(lpModal).not.toBeVisible();
+
+    // edit loadpoint
+    await page.getByTestId("loadpoint").nth(0).getByRole("button", { name: "edit" }).click();
+    await expect(lpModal).toContainText("No vehicles are configured.");
+    await lpModal.getByRole("button", { name: "Close" }).click();
+
+    // add vehicles
+    for (const title of [VEHICLE_1, VEHICLE_2]) {
+      await addVehicle(page, title);
+    }
+
+    // set vehicle as default for loadpoint 1
+    await page.getByTestId("loadpoint").nth(0).getByRole("button", { name: "edit" }).click();
+    await expect(lpModal).toBeVisible();
+    await lpModal.getByLabel("Default vehicle").selectOption(VEHICLE_1);
+    await lpModal.getByRole("button", { name: "Save" }).click();
+    await expect(lpModal).not.toBeVisible();
+
+    // add second loadpoint
+    await newLoadpoint(page, LP_2);
+    await addDemoCharger(page);
+    await lpModal.getByLabel("Default vehicle").selectOption(VEHICLE_2);
+    await lpModal.getByRole("button", { name: "Save" }).click();
+
+    // restart
+    await restart(CONFIG_EMPTY);
+    await page.reload();
+
+    // check loadpoint default vehicles
+    for (const [index, vehicle] of [VEHICLE_1, VEHICLE_2].entries()) {
+      await page.getByTestId("loadpoint").nth(index).getByRole("button", { name: "edit" }).click();
+      await expect(lpModal.locator("#loadpointParamVehicle option:checked")).toHaveText(vehicle);
+      await lpModal.getByRole("button", { name: "Close" }).click();
+      await expect(lpModal).not.toBeVisible();
+    }
+  });
+
+  test("keep mode", async ({ page }) => {
+    await start(CONFIG_EMPTY);
+    await page.goto("/#/config");
+    await enableExperimental(page);
+
+    // add grid meter
+    await page.getByRole("button", { name: "Add grid meter" }).click();
+    const meterModal = page.getByTestId("meter-modal");
+    await meterModal.getByLabel("Manufacturer").selectOption("Demo meter");
+    await meterModal.getByLabel("Power").fill("-1000");
+    await meterModal.getByRole("button", { name: "Save" }).click();
+    await expect(meterModal).not.toBeVisible();
+
+    // add a loadpoint with dummy charger,
+    const lpModal = page.getByTestId("loadpoint-modal");
+    await newLoadpoint(page, "Carport");
+    await addDemoCharger(page);
+    await lpModal.getByLabel("Default mode").selectOption("---");
+    await lpModal.getByRole("button", { name: "Save" }).click();
+    await expect(lpModal).not.toBeVisible();
+    await restart(CONFIG_EMPTY);
+
+    // change on main ui
+    await page.goto("/");
+    await expect(page.getByRole("button", { name: "Off" })).toHaveClass(/active/);
+    await page.getByRole("button", { name: "Solar", exact: true }).click();
+    await restart(CONFIG_EMPTY);
+    await page.reload();
+    await expect(page.getByRole("button", { name: "Solar", exact: true })).toHaveClass(/active/);
+
+    // change default mode in config to fast
+    await page.goto("/#/config");
+    // open first loadpoint
+    await page.getByTestId("loadpoint").getByRole("button", { name: "edit" }).click();
+    await expect(lpModal).toBeVisible();
+    await lpModal.getByLabel("Default mode").selectOption("Fast");
+    await lpModal.getByRole("button", { name: "Save" }).click();
+    await expect(lpModal).not.toBeVisible();
+    await restart(CONFIG_EMPTY);
+
+    // check loadpoint mode
+    await page.goto("/");
+    await expect(page.getByRole("button", { name: "Fast" })).toHaveClass(/active/);
+  });
+});
diff --git a/tests/config-onboarding.spec.js b/tests/config-onboarding.spec.js
new file mode 100644
index 00000000..15d10c1d
--- /dev/null
+++ b/tests/config-onboarding.spec.js
@@ -0,0 +1,101 @@
+import { test, expect } from "@playwright/test";
+import { start, stop, restart, baseUrl } from "./evcc";
+import { enableExperimental } from "./utils";
+
+const CONFIG = "config-empty.evcc.yaml";
+
+test.use({ baseURL: baseUrl() });
+
+test.beforeAll(async () => {
+  await start(CONFIG, null, "");
+});
+test.afterAll(async () => {
+  await stop();
+});
+
+const PASSWORD = "secret";
+
+test.describe("onboarding", async () => {
+  test("password, loadpoint, grid, pv", async ({ page }) => {
+    await page.goto("/");
+
+    // set admin password
+    const admin = page.getByTestId("password-modal");
+    await expect(admin).toBeVisible();
+    await admin.getByLabel("New password").fill(PASSWORD);
+    await admin.getByLabel("Repeat password").fill(PASSWORD);
+    await admin.getByRole("button", { name: "Create Password" }).click();
+    await expect(admin).not.toBeVisible();
+
+    // onboarding
+    await expect(page.locator("body")).toContainText("Hello aboard!");
+    await page.getByRole("link", { name: "Let's start configuration" }).click();
+
+    // login
+    const login = page.getByTestId("login-modal");
+    await expect(login).toBeVisible();
+    await login.getByLabel("Password").fill(PASSWORD);
+    await login.getByRole("button", { name: "Login" }).click();
+    await expect(login).not.toBeVisible();
+
+    // config page
+    await expect(page.getByRole("heading", { name: "Configuration" })).toBeVisible();
+    await enableExperimental(page);
+
+    // create loadpoint with charger
+    await page.getByTestId("add-loadpoint").click();
+    const lpModal = page.getByTestId("loadpoint-modal");
+    await lpModal.getByLabel("Title").fill("Solar Carport");
+    await lpModal.getByRole("button", { name: "Add charger" }).click();
+
+    const chargerModal = page.getByTestId("charger-modal");
+    await chargerModal.getByLabel("Manufacturer").selectOption("Demo charger");
+    await chargerModal.getByLabel("Charge status").selectOption("C");
+    await chargerModal.getByLabel("Power").fill("3000");
+    await chargerModal.getByRole("button", { name: "Save" }).click();
+    await expect(chargerModal).not.toBeVisible();
+    await lpModal.getByRole("button", { name: "Save" }).click();
+    await expect(lpModal).not.toBeVisible();
+
+    // create grid meter
+    await page.getByRole("button", { name: "Add grid meter" }).click();
+    const gridModal = page.getByTestId("meter-modal");
+    await expect(gridModal).toBeVisible();
+    await gridModal.getByLabel("Manufacturer").selectOption("Demo meter");
+    await gridModal.getByLabel("Power").fill("-2000");
+    await gridModal.getByRole("button", { name: "Save" }).click();
+    await expect(gridModal).not.toBeVisible();
+
+    // create pv meter
+    await page.getByRole("button", { name: "Add solar or battery" }).click();
+    const pvModal = page.getByTestId("meter-modal");
+    await expect(gridModal).toBeVisible();
+    await pvModal.getByRole("button", { name: "Add solar meter" }).click();
+    await pvModal.getByLabel("Manufacturer").selectOption("Demo meter");
+    await pvModal.getByLabel("Power").fill("5000");
+    await pvModal.getByRole("button", { name: "Save" }).click();
+    await expect(pvModal).not.toBeVisible();
+
+    // restart
+    const restartButton = await page
+      .getByTestId("bottom-banner")
+      .getByRole("button", { name: "Restart" });
+    await expect(restartButton).toBeVisible();
+    await restart(CONFIG);
+    await expect(restartButton).not.toBeVisible();
+
+    // navigate to main screen
+    await page.getByTestId("home-link").click();
+
+    // verify configuration
+    await page.getByTestId("visualization").click();
+    await expect(page.getByTestId("energyflow-entry-production")).toContainText("5.0 kW");
+    await expect(page.getByTestId("energyflow-entry-loadpoints")).toContainText("3.0 kW");
+    await expect(page.getByTestId("energyflow-entry-gridexport")).toContainText("2.0 kW");
+
+    const loadpoint = page.getByTestId("loadpoint");
+    await expect(loadpoint.getByRole("heading", { name: "Solar Carport" })).toBeVisible();
+    await expect(loadpoint).toContainText("3.0 kW");
+    await expect(loadpoint).toContainText("Charging…");
+  });
+});
diff --git a/tests/config-one-lp.evcc.yaml b/tests/config-one-lp.evcc.yaml
new file mode 100755
index 00000000..c7b0b651
--- /dev/null
+++ b/tests/config-one-lp.evcc.yaml
@@ -0,0 +1,24 @@
+site:
+  title: Hello World
+
+loadpoints:
+  - title: Carport
+    charger: charger
+
+chargers:
+  - name: charger
+    type: custom
+    enable:
+      source: js
+      script:
+    enabled:
+      source: js
+      script: |
+        false
+    status:
+      source: js
+      script: |
+        "B"
+    maxcurrent:
+      source: js
+      script:
diff --git a/tests/config-tariffs.spec.js b/tests/config-tariffs.spec.js
index 9d900497..26331968 100644
--- a/tests/config-tariffs.spec.js
+++ b/tests/config-tariffs.spec.js
@@ -24,17 +24,15 @@ test.describe("tariffs", async () => {
     await start(CONFIG_GRID_ONLY);
     await goToConfig(page);
 
-    await expect(page.getByTestId("tariffs")).toBeVisible();
-    await expect(page.getByTestId("tariffs")).toContainText(
-      ["Tariffs", "Currency", "EUR"].join("")
-    );
+    await expect(page.getByTestId("tariffs")).not.toBeVisible();
+    await expect(page.getByTestId("add-tariffs")).toBeVisible();
   });
 
   test("tariffs via ui", async ({ page }) => {
     await start(CONFIG_GRID_ONLY);
     await goToConfig(page);
 
-    await page.getByTestId("tariffs").getByRole("button", { name: "edit" }).click();
+    await page.getByTestId("add-tariffs").click();
     const modal = await page.getByTestId("tariffs-modal");
     await expect(modal).toBeVisible();
     await page.waitForLoadState("networkidle");
diff --git a/tests/simulator/api.js b/tests/simulator/api.js
index 77ad4770..11e8b328 100644
--- a/tests/simulator/api.js
+++ b/tests/simulator/api.js
@@ -42,6 +42,30 @@ const openemsMiddleware = (req, res, next) => {
   }
 };
 
+const teslaloggerMiddleware = (req, res, next) => {
+  if (req.method === "GET" && req.originalUrl.startsWith("/currentjson/")) {
+    const id = parseInt(req.originalUrl.split("/")[2]);
+    const vehicle = state.vehicles[id - 1];
+    if (!vehicle) {
+      res.statusCode = 404;
+      res.end(JSON.stringify({ error: "Vehicle not found" }));
+      return;
+    }
+    const data = {
+      battery_level: vehicle.soc,
+      battery_range_km: vehicle.range,
+      plugged_in: true,
+      charging: false,
+      odometer: 10000,
+      is_preconditioning: false,
+      charge_current_request: 10,
+    };
+    res.end(JSON.stringify(data));
+  } else {
+    next();
+  }
+};
+
 export default () => ({
   name: "api",
   enforce: "pre",
@@ -51,6 +75,7 @@ export default () => ({
       server.middlewares.use(bodyParser.json());
       server.middlewares.use(stateApiMiddleware);
       server.middlewares.use(openemsMiddleware);
+      server.middlewares.use(teslaloggerMiddleware);
     };
   },
 });