Repository: evcc-io/evcc

Issue:
KIA's caching causing missing data and server overload
### Describe the bug

I'm having some random issues when setting up my KIA integration, and I've traced the issue down to the way the API is called, and cached.

Looking at https://github.com/evcc-io/evcc/blob/master/vehicle/bluelink/provider.go it seems that the API is calling statusG will try and cache the call to status()

The first time this is called, it will call the API, and only when the `resMsg.lastUpdatedTime` is below the "expire" threshold, use the result. If it's expired, it will fall through to the "v.refreshTime.IsZero" block, and call the API again using refreshG, but again with the same result. It will set the refreshTime but as you will see in a moment, that is useless.

The error is set to api.ErrMustRetry and the method returns with nil, err

30 seconds later, evcc wants to try again. You would expect that the refreshTime will prevent another API call, but alas: I think because the last attempt returned an error, the result was not cached, and it will call statusG again, uncached! This time it will skip the "v.refreshTime.IsZero" block, so there is no second API call, but at that point the damage is done.

This will continue every EVCC interval until the KIA server give a lastUpdatedTime of <5min. However, I have found that they cache their responses for over an hour now (at least if the car is asleep). That's 60*2 = 120 fruitless API calls.

It doesn't seem that this "expire" is configurable in evcc, but I think it's fine to use an hour old data from the KIA API if we currently have no data at all. I know that KIA changed their API a lot lately, some maybe this server-side caching is new, but it seems like an important thing to handle now :)

If you need more info, please let me know!

### Steps to reproduce

1. Configure vehicle (see below)
2. docker run -v $(pwd)/evcc.yaml:/app/evcc.yaml -it evcc/evcc:nightly evcc -l trace vehicle
3. If the lastUpdateTime is >5m ago, you will see continous calls to the ccs2/carstatus/latest endpoint
4. After long enough, it will time out

if lastUpdateTime is <5m ago, you will get your car details quickly, and the answer is cached


### Configuration details

```yaml
vehicles:
- type: template
  template: kia
  user: <username here>
  password: <refresh token here>
  title: Kia EV3
  capacity: 55
  language: en
  name: ev3
  cache: 15m
```

### Log details

```text
[kia   ] TRACE 2025/11/28 12:35:11 POST https://idpconnect-eu.kia.com/auth/api/v2/user/oauth2/token
[kia   ] TRACE 2025/11/28 12:35:11 client_id=***&client_secret=***&grant_type=refresh_token&refresh_token=***
--
{"scope":null,"idp_cd":"hmgid1.0","access_token":"***","refresh_token":null,"id_token":null,"token_type":"Bearer","expires_in":3600}
[kia   ] TRACE 2025/11/28 12:35:11 POST https://prd.eu-ccapi.kia.com:8080/api/v1/spa/notifications/register
[kia   ] TRACE 2025/11/28 12:35:11 {"pushRegId":"***","pushType":"APNS","uuid":"***"}
--
{"retCode":"S","resCode":"0000","resMsg":{"deviceId":"***"},"msgId":"***"}
[kia   ] TRACE 2025/11/28 12:35:11 GET https://prd.eu-ccapi.kia.com:8080/api/v1/spa/vehicles
[kia   ] TRACE 2025/11/28 12:35:11 {"retCode":"S","resCode":"0000","resMsg":{"vehicles":[{"vin":"***","vehicleId":"***","vehicleName":"EV3","type":"EV","tmuNum":"-","nickname":"EV3","year":"2025","master":true,"carShare":0,"regDate":"2025-11-13 14:04:34.400","personalFlag":"4","detailInfo":{"inColor":"ELG","outColor":"EBD","prodCarmdlCd":"9V","saleCarmdlCd":"9V","bodyType":"2","saleCarmdlEnNm":"EV3"},"protocolType":1,"ccuCCS2ProtocolSupport":1}]},"msgId":"***"}
[kia   ] TRACE 2025/11/28 12:35:11 GET https://prd.eu-ccapi.kia.com:8080/api/v1/spa/vehicles/***/ccs2/carstatus/latest
[kia   ] TRACE 2025/11/28 12:35:12 {"retCode":"S","resCode":"0000","resMsg":{"resCode":"0000","ServiceNo":"RVS-K","RetCode":"S","lastUpdateTime":"1764332217006","state":{"Vehicle":{"Location":{"Date":"20251128090727.404","GeoCoord":{"Altitude":0,"Latitude":***,"Longitude":***,"Type":0},"Heading":201,"Offset":1,"Servicestate":0,"Speed":{"Unit":0,"Value":0},"TimeStamp":{"Day":28,"Hour":10,"Min":7,"Mon":11,"Sec":27,"Year":2025},"Version":"HU2025112810072701"},"Body":{[... vehicle status data ...]}}}},"msgId":"***"}
[kia   ] TRACE 2025/11/28 12:35:12 GET https://prd.eu-ccapi.kia.com:8080/api/v1/spa/vehicles/***/ccs2/carstatus/latest
[kia   ] TRACE 2025/11/28 12:35:12 {"retCode":"S","resCode":"0000","resMsg":{"resCode":"0000","ServiceNo":"RVS-K","RetCode":"S","lastUpdateTime":"1764332217006","state":{"Vehicle":{"Location":{"Date":"20251128090727.404","GeoCoord":{"Altitude":0,"Latitude":***,"Longitude":***,"Type":0},"Heading":201,"Offset":1,"Servicestate":0,"Speed":{"Unit":0,"Value":0},"TimeStamp":{"Day":28,"Hour":10,"Min":7,"Mon":11,"Sec":27,"Year":2025},"Version":"HU2025112810072701"},"Body":{[... vehicle status data ...]}}}},"msgId":"***"}

(last call repeats every 30 seconds)
```

### What type of operating system or environment does evcc run on?

Docker container

### External automation

- [x] I have made sure that no external automation like HomeAssistant or Node-RED is active or accessing any of the mentioned devices when this issue occurs.

### Nightly build

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

### Version

evcc version 0.210.2 (8fb8cee72)

Discussion:
@andig: Please feel free to open a PR since you serm to understand where in the code this needs to happen and can test. @mluiten: I can manage reading Go, but writing it a different story... also, I'm not sure what your other providers are doing and what your preferred solution would be: 

1. should the "expired" response be cached and used, but we try to get new data every refresh interval (2 minutes)
2. should the "expired" response be cached and used, but we retry every evcc interval (30 seconds)
3. should the "expired" response _not_ be cached and used, and we retry every refresh interval (2 minutes)

I _think_ the intention is/was option 1, but I'm not sure and I cannot find tests to verify.

I can give a go (no pun intended) but it will take some time 😄 @mluiten: Actually, looking at some other providers, most seem to have either commented out, or removed these kinds of expiration checks. I can make a proposal with a much simplified fetch (just grab the latest data) to match those.@andig: I guess so. Loadpoint will already cache for 15min by default, and so will vehicle provider. @mluiten: I have a branch where the code is simplified a lot, and it works brilliantly for my car. Looking for another KIA/Hyundai evcc user with an older vehicle that does not support "ccuCCS2ProtocolSupport" to verify if it also works for them. Specifically for those vehicles it would call a different endpoint if the "latest" endpoint data was considered stale, which would/should wake up the car. That sounds like a very bad idea to me to be honest, as it will eat SoC every 5 minutes... otherwise have to put some code back, but either way should be an improvement.

(Feel free to reach out in a PM if you are that user)@0x3d13f: I have a Hyundai Ioniq electric from 2021. How can I find out if it supports "ccuCCS2ProtocolSupport" or not?@0x3d13f: I would assume this is supported when the car has DC charging via CCS connector?@mluiten: > I have a Hyundai Ioniq electric from 2021. How can I find out if it supports "ccuCCS2ProtocolSupport" or not?

If you head to "Logs" in EVCC, then filter on your car, and set log-level to "TRACE", you can search for ccuCCS2ProtocolSupport -- it would be in one of the first messages after start-up, and it would either say "ccuCCS2ProtocolSupport": 1 or "ccuCCS2ProtocolSupport": 0.... in that last case, please contact me 😄 

<img width="1314" height="294" alt="Image" src="https://github.com/user-attachments/assets/116704cc-2aee-4742-abe9-4ddb0d4037f0" />@0x3d13f: I see `"ccuCCS2ProtocolSupport":0`, I will contact you via mail.@VolkerK62: >it would either say "ccuCCS2ProtocolSupport": 1 or "ccuCCS2ProtocolSupport": 0.... 

Not sure, if this is important, but my Ioniq5 (2021) do not have this Information `ccuCCS2ProtocolSupport` within the Trace log@0x3d13f: I had to restart evcc to see it in the logs.@VolkerK62: Ah, good catch. It´s also `0`@andig: OT: anybody having issues with vehicle integration should support the petition to force car manufacturers to give proper API access to user data: https://github.com/evcc-io/evcc/discussions/23684#discussioncomment-15111779

Solution patch:
diff --git a/api/globalconfig/types.go b/api/globalconfig/types.go
index e45ea26b..3756f2f8 100644
--- a/api/globalconfig/types.go
+++ b/api/globalconfig/types.go
@@ -9,6 +9,7 @@ import (
 	"time"
 
 	"github.com/evcc-io/evcc/api"
+	"github.com/evcc-io/evcc/charger/ocpp"
 	"github.com/evcc-io/evcc/hems/shm"
 	"github.com/evcc-io/evcc/plugin/mqtt"
 	"github.com/evcc-io/evcc/server/eebus"
@@ -19,6 +20,7 @@ import (
 
 type All struct {
 	Network      Network
+	Ocpp         ocpp.Config
 	Log          string
 	SponsorToken string
 	Plant        string // telemetry plant id
diff --git a/assets/js/components/Config/ChargerModal.vue b/assets/js/components/Config/ChargerModal.vue
index ced53233..975607da 100644
--- a/assets/js/components/Config/ChargerModal.vue
+++ b/assets/js/components/Config/ChargerModal.vue
@@ -16,20 +16,67 @@
 		:apply-custom-defaults="applyCustomDefaults"
 		:custom-fields="customFields"
 		:get-product-name="getProductName"
+		:hide-template-fields="hideTemplateFields"
 		@added="$emit('added', $event)"
 		@updated="$emit('updated')"
 		@removed="$emit('removed')"
 		@close="$emit('close')"
+		@reset="reset"
 	>
-		<template #after-template-info>
+		<template v-if="isOcpp" #template-description>
+			<p>{{ $t("config.charger.ocppDescription") }}</p>
+			<ol class="mb-4">
+				<li>{{ $t("config.charger.ocppStep1") }}</li>
+				<li>{{ $t("config.charger.ocppStep2") }}</li>
+				<li>{{ $t("config.charger.ocppStep3") }}</li>
+			</ol>
+		</template>
+
+		<template v-if="isOcpp" #after-template-info="{ values }">
 			<FormRow
-				v-if="ocppUrl"
 				id="chargerOcppUrl"
 				:label="$t('config.charger.ocppLabel')"
-				:help="$t('config.charger.ocppHelp')"
+				:help="$t('config.charger.ocppHelp', { url: ocppUrlWithStationId })"
 			>
-				<input type="text" class="form-control border" :value="ocppUrl" readonly />
+				<input
+					id="chargerOcppUrl"
+					type="text"
+					class="form-control border"
+					:value="ocppUrl"
+					readonly
+				/>
 			</FormRow>
+
+			<div
+				v-if="!ocppNextStepConfirmed && !values.stationid"
+				class="my-4 d-flex justify-content-end gap-2 align-items-center"
+			>
+				<span v-if="ocppStationIdDetected">{{ $t("config.charger.ocppConnected") }}</span>
+				<button
+					v-if="ocppStationIdDetected"
+					type="button"
+					class="btn btn-primary text-nowrap ms-2"
+					@click="
+						values.stationid = ocppStationIdDetected;
+						ocppNextStepConfirmed = true;
+					"
+				>
+					{{ $t("config.charger.ocppNextStep") }}
+				</button>
+				<button
+					v-else
+					type="button"
+					class="btn btn-outline-primary text-nowrap"
+					@click="confirmOcppNextStep"
+				>
+					<span
+						class="spinner-border spinner-border-sm me-2"
+						role="status"
+						aria-hidden="true"
+					></span>
+					{{ $t("config.charger.ocppWaiting") }}
+				</button>
+			</div>
 		</template>
 	</DeviceModalBase>
 </template>
@@ -56,7 +103,8 @@ import switchsocketHeaterYaml from "./defaultYaml/switchsocketHeater.yaml?raw";
 import switchsocketChargerYaml from "./defaultYaml/switchsocketCharger.yaml?raw";
 import sgreadyYaml from "./defaultYaml/sgready.yaml?raw";
 import sgreadyRelayYaml from "./defaultYaml/sgreadyRelay.yaml?raw";
-import { LOADPOINT_TYPE, type LoadpointType } from "@/types/evcc";
+import { LOADPOINT_TYPE, type LoadpointType, type Ocpp } from "@/types/evcc";
+import { getOcppUrl, getOcppUrlWithStationId } from "@/utils/ocpp";
 
 const initialValues = {
 	type: ConfigType.Template,
@@ -78,6 +126,10 @@ export default defineComponent({
 		id: Number,
 		loadpointType: { type: String as PropType<LoadpointType>, default: null },
 		fade: String as PropType<ModalFade>,
+		ocpp: {
+			type: Object as PropType<Ocpp>,
+			default: () => ({ config: { port: 0 }, status: { stations: [] } }),
+		},
 		isSponsor: Boolean,
 	},
 	emits: ["added", "updated", "removed", "close"],
@@ -85,6 +137,7 @@ export default defineComponent({
 		return {
 			initialValues,
 			customFields: CUSTOM_FIELDS,
+			ocppNextStepConfirmed: false,
 			currentTemplate: null as Template | null,
 			currentValues: {} as DeviceValues,
 		};
@@ -102,14 +155,28 @@ export default defineComponent({
 		isHeating(): boolean {
 			return this.loadpointType === LOADPOINT_TYPE.HEATING;
 		},
-		ocppUrl(): string | null {
-			const isOcpp =
+		isOcpp(): boolean {
+			return (
+				this.currentTemplate !== null &&
 				this.currentTemplate?.Params.some((p: TemplateParam) => p.Name === "connector") &&
-				this.currentTemplate?.Params.some((p: TemplateParam) => p.Name === "stationid");
-			if (isOcpp && this.currentValues) {
-				return `ws://${window.location.hostname}:8887/${this.currentValues.stationid || ""}`;
+				this.currentTemplate?.Params.some((p: TemplateParam) => p.Name === "stationid")
+			);
+		},
+		ocppUrl(): string | null {
+			return this.isOcpp ? getOcppUrl(this.ocpp) : null;
+		},
+		ocppUrlWithStationId(): string | null {
+			return this.isOcpp ? getOcppUrlWithStationId(this.ocpp) : null;
+		},
+		ocppStationIdDetected(): string | undefined {
+			if (!this.isOcpp) {
+				return undefined;
 			}
-			return null;
+			const stations = this.ocpp.status.stations;
+			return stations.find((station) => station.status === "unknown")?.id;
+		},
+		hideTemplateFields(): boolean {
+			return this.isOcpp && !this.ocppNextStepConfirmed;
 		},
 	},
 	methods: {
@@ -244,6 +311,16 @@ export default defineComponent({
 			// For template types, use the standard logic (deviceProduct or templateName)
 			return values.deviceProduct || templateName || "";
 		},
+		confirmOcppNextStep() {
+			if (window.confirm(this.$t("config.charger.ocppConfirmContinue"))) {
+				this.ocppNextStepConfirmed = true;
+			}
+		},
+		reset() {
+			this.ocppNextStepConfirmed = false;
+			this.currentTemplate = null;
+			this.currentValues = {} as DeviceValues;
+		},
 	},
 });
 </script>
diff --git a/assets/js/components/Config/DeviceModal/DeviceModalBase.vue b/assets/js/components/Config/DeviceModal/DeviceModalBase.vue
index ca9c0f48..87ad0557 100644
--- a/assets/js/components/Config/DeviceModal/DeviceModalBase.vue
+++ b/assets/js/components/Config/DeviceModal/DeviceModalBase.vue
@@ -43,7 +43,9 @@
 				<div v-else>
 					<p v-if="loadingTemplate">{{ $t("config.general.templateLoading") }}</p>
 					<SponsorTokenRequired v-if="sponsorTokenRequired" />
-					<Markdown v-if="description" :markdown="description" class="my-4" />
+					<slot name="template-description">
+						<Markdown v-if="description" :markdown="description" class="my-4" />
+					</slot>
 
 					<div v-if="authRequired">
 						<PropertyEntry
@@ -99,47 +101,49 @@
 					<div v-else>
 						<slot name="after-template-info" :values="values"></slot>
 
-						<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']"
-							component-id="device"
-							:defaultId="modbus.ID ? Number(modbus.ID) : undefined"
-							:defaultComset="modbus.Comset"
-							:defaultBaudrate="modbus.Baudrate"
-							:defaultPort="modbus.Port"
-							:capabilities="modbusCapabilities"
-						/>
+						<div v-if="!hideTemplateFields">
+							<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']"
+								component-id="device"
+								:defaultId="modbus.ID ? Number(modbus.ID) : undefined"
+								:defaultComset="modbus.Comset"
+								:defaultBaudrate="modbus.Baudrate"
+								:defaultPort="modbus.Port"
+								:capabilities="modbusCapabilities"
+							/>
 
-						<PropertyEntry
-							v-for="param in normalParams"
-							:id="`${deviceType}Param${param.Name}`"
-							:key="param.Name"
-							v-bind="param"
-							v-model="values[param.Name]"
-							:service-values="serviceValues[param.Name]"
-						/>
+							<PropertyEntry
+								v-for="param in normalParams"
+								:id="`${deviceType}Param${param.Name}`"
+								:key="param.Name"
+								v-bind="param"
+								v-model="values[param.Name]"
+								:service-values="serviceValues[param.Name]"
+							/>
 
-						<PropertyCollapsible>
-							<template v-if="advancedParams.length" #advanced>
-								<PropertyEntry
-									v-for="param in advancedParams"
-									:id="`${deviceType}Param${param.Name}`"
-									:key="param.Name"
-									v-bind="param"
-									v-model="values[param.Name]"
-									:service-values="serviceValues[param.Name]"
-								/>
-							</template>
-							<template v-if="$slots['collapsible-more']" #more>
-								<slot name="collapsible-more" :values="values"></slot>
-							</template>
-						</PropertyCollapsible>
+							<PropertyCollapsible>
+								<template v-if="advancedParams.length" #advanced>
+									<PropertyEntry
+										v-for="param in advancedParams"
+										:id="`${deviceType}Param${param.Name}`"
+										:key="param.Name"
+										v-bind="param"
+										v-model="values[param.Name]"
+										:service-values="serviceValues[param.Name]"
+									/>
+								</template>
+								<template v-if="$slots['collapsible-more']" #more>
+									<slot name="collapsible-more" :values="values"></slot>
+								</template>
+							</PropertyCollapsible>
+						</div>
 					</div>
 				</div>
 
@@ -252,8 +256,18 @@ export default defineComponent({
 		onConfigurationLoaded: Function as PropType<(values: DeviceValues) => void>,
 		// Optional: external template selection control (for parent to reset template)
 		externalTemplate: String as PropType<string | null>,
+		// Optional: hide template fields, e.g. because ocpp step was not completed
+		hideTemplateFields: { type: Boolean, default: false },
 	},
-	emits: ["added", "updated", "removed", "close", "template-changed", "update:externalTemplate"],
+	emits: [
+		"added",
+		"updated",
+		"removed",
+		"close",
+		"template-changed",
+		"update:externalTemplate",
+		"reset",
+	],
 	data() {
 		return {
 			isModalVisible: false,
@@ -367,7 +381,19 @@ export default defineComponent({
 			return !this.isNew;
 		},
 		showActions() {
-			return (this.templateName && !this.authRequired) || this.showYamlInput;
+			// explicitly hide template fields (ocpp step 1)
+			if (this.hideTemplateFields) {
+				return false;
+			}
+			// yaml input type
+			if (this.showYamlInput) {
+				return true;
+			}
+			// template selected and no auth prerequisit
+			if (this.templateName && !this.authRequired) {
+				return true;
+			}
+			return false;
 		},
 		showYamlInput() {
 			return this.isYamlInputTypeByValue(this.values.type);
@@ -499,6 +525,7 @@ export default defineComponent({
 			this.values = { ...this.initialValues } as DeviceValues;
 			this.test = initialTestState();
 			this.resetAuthStatus();
+			this.$emit("reset");
 		},
 		async loadConfiguration() {
 			try {
diff --git a/assets/js/components/Config/OcppModal.vue b/assets/js/components/Config/OcppModal.vue
new file mode 100644
index 00000000..fc7aa529
--- /dev/null
+++ b/assets/js/components/Config/OcppModal.vue
@@ -0,0 +1,143 @@
+<template>
+	<GenericModal id="ocppModal" :title="$t('config.ocpp.title')" data-testid="ocpp-modal">
+		<div class="container">
+			<!-- OCPP URL -->
+			<div class="mb-3">
+				<Markdown :markdown="$t('config.ocpp.urlHelp', { url: ocppUrlWithStationId })" />
+			</div>
+			<FormRow id="ocppModalServerUrl" :label="$t('config.ocpp.url')">
+				<input
+					id="ocppModalServerUrl"
+					type="text"
+					class="form-control border"
+					:value="ocppUrl"
+					readonly
+				/>
+			</FormRow>
+
+			<hr class="my-4" />
+
+			<!-- No chargers message -->
+			<div
+				v-if="connectedStations.length === 0 && detectedStations.length === 0"
+				class="text-muted"
+			>
+				{{ $t("config.ocpp.noChargers") }}
+			</div>
+
+			<!-- Connection status -->
+			<div v-if="connectedStations.length > 0">
+				<h6 class="mb-3">{{ $t("config.ocpp.connectionStatus") }}</h6>
+				<p class="text-muted small mb-3">{{ $t("config.ocpp.connectionStatusHelp") }}</p>
+
+				<ul class="list-group stations-list mb-4">
+					<li
+						v-for="station in connectedStations"
+						:key="station.id"
+						class="list-group-item d-flex justify-content-between align-items-center"
+					>
+						<code>{{ station.id }}</code>
+						<span
+							class="badge"
+							:class="statusBadgeClass(station.status)"
+							:title="$t(`config.ocpp.status.${station.status}`)"
+							data-bs-toggle="tooltip"
+						>
+							{{ $t(`config.ocpp.status.${station.status}`) }}
+						</span>
+					</li>
+				</ul>
+			</div>
+
+			<!-- Detected chargers -->
+			<div v-if="detectedStations.length > 0">
+				<h6 class="mb-3">{{ $t("config.ocpp.detectedChargers") }}</h6>
+				<p class="text-muted small mb-3">{{ $t("config.ocpp.detectedHelp") }}</p>
+
+				<ul class="list-group stations-list">
+					<li
+						v-for="station in detectedStations"
+						:key="station.id"
+						class="list-group-item d-flex justify-content-between align-items-center"
+					>
+						<code>{{ station.id }}</code>
+						<span class="badge bg-secondary">
+							{{ $t(`config.ocpp.status.${station.status}`) }}
+						</span>
+					</li>
+				</ul>
+			</div>
+		</div>
+	</GenericModal>
+</template>
+
+<script lang="ts">
+import { defineComponent, type PropType } from "vue";
+import GenericModal from "../Helper/GenericModal.vue";
+import FormRow from "./FormRow.vue";
+import Markdown from "./Markdown.vue";
+import type { Ocpp, OcppStationStatus } from "@/types/evcc";
+import { getOcppUrl, getOcppUrlWithStationId } from "@/utils/ocpp";
+
+export default defineComponent({
+	name: "OcppModal",
+	components: {
+		GenericModal,
+		FormRow,
+		Markdown,
+	},
+	props: {
+		ocpp: {
+			type: Object as PropType<Ocpp>,
+			default: () => ({ config: { port: 0 }, status: { stations: [] } }),
+		},
+	},
+	computed: {
+		status() {
+			return this.ocpp.status;
+		},
+		stations() {
+			return this.status.stations;
+		},
+		ocppUrl(): string {
+			return getOcppUrl(this.ocpp);
+		},
+		ocppUrlWithStationId(): string {
+			return getOcppUrlWithStationId(this.ocpp);
+		},
+		connectedStations(): OcppStationStatus[] {
+			return this.stations.filter(
+				(s) => s.status === "connected" || s.status === "configured"
+			);
+		},
+		detectedStations(): OcppStationStatus[] {
+			return this.stations.filter((s) => s.status === "unknown");
+		},
+	},
+	methods: {
+		statusBadgeClass(status: string): string {
+			switch (status) {
+				case "connected":
+					return "bg-success";
+				case "configured":
+					return "bg-warning";
+				case "unknown":
+					return "bg-secondary";
+				default:
+					return "bg-secondary";
+			}
+		},
+	},
+});
+</script>
+
+<style scoped>
+.container {
+	padding-right: 0;
+	padding-left: 0;
+}
+
+.list-group-item code {
+	font-size: 0.9rem;
+}
+</style>
diff --git a/assets/js/components/MaterialIcon/Ocpp.vue b/assets/js/components/MaterialIcon/Ocpp.vue
new file mode 100644
index 00000000..ca229d14
--- /dev/null
+++ b/assets/js/components/MaterialIcon/Ocpp.vue
@@ -0,0 +1,18 @@
+<template>
+	<svg :style="svgStyle" viewBox="0 0 24 24">
+		<path
+			fill="currentColor"
+			d="M3 16v-3H2q-.425 0-.712-.288T1 12t.288-.712T2 11h1V8q0-1.25.875-2.125T6 5h1q0-.425.288-.712T8 4t.713.288T9 5v14q0 .425-.288.713T8 20t-.712-.288T7 19H6q-1.25 0-2.125-.875T3 16m3 1h1V7H6q-.425 0-.712.288T5 8v8q0 .425.288.713T6 17m15-1q0 1.25-.875 2.125T18 19h-1q0 .425-.288.713T16 20t-.712-.288T15 19v-3h-3q-.425 0-.712-.288T11 15t.288-.712T12 14h3v-4h-3q-.425 0-.712-.288T11 9t.288-.712T12 8h3V5q0-.425.288-.712T16 4t.713.288T17 5h1q1.25 0 2.125.875T21 8v3h1q.425 0 .713.288T23 12t-.288.713T22 13h-1zm-4 1h1q.425 0 .713-.288T19 16V8q0-.425-.288-.712T18 7h-1zm0-5"
+		/>
+	</svg>
+</template>
+
+<script lang="ts">
+import { defineComponent } from "vue";
+import icon from "@/mixins/icon";
+
+export default defineComponent({
+	name: "Ocpp",
+	mixins: [icon],
+});
+</script>
diff --git a/assets/js/types/evcc.ts b/assets/js/types/evcc.ts
index 129e8e1b..bb124f60 100644
--- a/assets/js/types/evcc.ts
+++ b/assets/js/types/evcc.ts
@@ -92,6 +92,32 @@ export interface State {
   authDisabled?: boolean;
   config?: string;
   database?: string;
+  ocpp?: Ocpp;
+}
+
+export interface OcppConfig {
+  port: number;
+}
+
+export interface OcppStatus {
+  externalUrl?: string;
+  stations: OcppStationStatus[];
+}
+
+export interface Ocpp {
+  config: OcppConfig;
+  status: OcppStatus;
+}
+
+export interface OcppStationStatus {
+  id: string;
+  status: OCPP_STATION_STATUS;
+}
+
+export enum OCPP_STATION_STATUS {
+  UNKNOWN = "unknown",
+  CONFIGURED = "configured",
+  CONNECTED = "connected",
 }
 
 export interface Config {
diff --git a/assets/js/utils/ocpp.ts b/assets/js/utils/ocpp.ts
new file mode 100644
index 00000000..79915c01
--- /dev/null
+++ b/assets/js/utils/ocpp.ts
@@ -0,0 +1,13 @@
+import type { Ocpp } from "@/types/evcc";
+
+export function getOcppUrl(ocpp: Ocpp): string {
+  // User specified url, e.g., for reverse proxy setups
+  if (ocpp.status.externalUrl) {
+    return ocpp.status.externalUrl;
+  }
+  return `ws://${window.location.hostname}:${ocpp.config.port}/`;
+}
+
+export function getOcppUrlWithStationId(ocpp: Ocpp): string {
+  return `${getOcppUrl(ocpp)}<stationId>`;
+}
diff --git a/assets/js/views/Config.vue b/assets/js/views/Config.vue
index 45710ba8..d14c998b 100644
--- a/assets/js/views/Config.vue
+++ b/assets/js/views/Config.vue
@@ -234,6 +234,18 @@
 								<DeviceTags :tags="eebusTags" />
 							</template>
 						</DeviceCard>
+						<DeviceCard
+							:title="$t('config.ocpp.title')"
+							editable
+							:error="hasClassError('ocpp')"
+							data-testid="ocpp"
+							@edit="openModal('ocppModal')"
+						>
+							<template #icon><OcppIcon /></template>
+							<template #tags>
+								<DeviceTags :tags="ocppTags" />
+							</template>
+						</DeviceCard>
 
 						<DeviceCard
 							:title="`${$t('config.circuits.title')} 🧪`"
@@ -359,6 +371,7 @@
 					:loadpointType="selectedLoadpointType"
 					:fade="loadpointSubModalOpen ? 'right' : undefined"
 					:is-sponsor="isSponsor"
+					:ocpp="ocpp"
 					@added="chargerAdded"
 					@updated="chargerChanged"
 					@removed="chargerRemoved"
@@ -380,6 +393,7 @@
 					@changed="yamlChanged"
 				/>
 				<EebusModal @changed="yamlChanged" />
+				<OcppModal :ocpp="ocpp" />
 				<BackupRestoreModal v-bind="backupRestoreProps" />
 				<PasswordModal update-mode />
 				<SponsorModal :error="hasClassError('sponsorship')" @changed="loadDirty" />
@@ -404,6 +418,8 @@ import DeviceCard from "../components/Config/DeviceCard.vue";
 import DeviceTags from "../components/Config/DeviceTags.vue";
 import EebusIcon from "../components/MaterialIcon/Eebus.vue";
 import EebusModal from "../components/Config/EebusModal.vue";
+import OcppIcon from "../components/MaterialIcon/Ocpp.vue";
+import OcppModal from "../components/Config/OcppModal.vue";
 import formatter from "../mixins/formatter";
 import GeneralConfig from "../components/Config/GeneralConfig.vue";
 import HemsIcon from "../components/MaterialIcon/Hems.vue";
@@ -469,6 +485,8 @@ export default defineComponent({
 		DeviceTags,
 		EebusIcon,
 		EebusModal,
+		OcppIcon,
+		OcppModal,
 		ExperimentalBanner,
 		AuthSuccessBanner,
 		GeneralConfig,
@@ -665,6 +683,9 @@ export default defineComponent({
 		isSponsor(): boolean {
 			return !!this.sponsor?.status.name;
 		},
+		ocpp() {
+			return store.state?.ocpp;
+		},
 		telemetry() {
 			// @ts-expect-error: telemetry property exists but not in TypeScript definitions
 			return store.state?.telemetry === true;
@@ -672,6 +693,28 @@ export default defineComponent({
 		eebusTags() {
 			return { configured: { value: store.state?.eebus || false } };
 		},
+		ocppTags() {
+			const ocpp = store.state?.ocpp;
+			const stations = ocpp?.status?.stations || [];
+			if (stations.length === 0) {
+				return { configured: { value: false } };
+			}
+
+			const connected = stations.filter((s) => s.status === "connected").length;
+			const configured = stations.filter((s) => s.status === "configured").length;
+			const detected = stations.filter((s) => s.status === "unknown").length;
+			const total = connected + configured;
+
+			const tags: Record<string, any> = {
+				connections: { value: `${connected}/${total}` },
+			};
+
+			if (detected > 0) {
+				tags["detected"] = { value: detected };
+			}
+
+			return tags;
+		},
 		modbusproxyTags() {
 			const config = store.state?.modbusproxy || [];
 			if (config.length > 0) {
diff --git a/charger/ocpp/cs.go b/charger/ocpp/cs.go
index 533da9bf..2131daa5 100644
--- a/charger/ocpp/cs.go
+++ b/charger/ocpp/cs.go
@@ -24,10 +24,62 @@ func newRegistration() *registration {
 
 type CS struct {
 	ocpp16.CentralSystem
-	mu    sync.Mutex
-	log   *util.Logger
-	regs  map[string]*registration // guarded by mu mutex
-	txnId atomic.Int64
+	mu          sync.Mutex
+	log         *util.Logger
+	regs        map[string]*registration // guarded by mu mutex
+	txnId       atomic.Int64
+	publishFunc func()
+}
+
+type stationStatus struct {
+	ID     string        `json:"id"`
+	Status StationStatus `json:"status"`
+}
+
+// Status represents the runtime OCPP status
+type Status struct {
+	ExternalUrl string          `json:"externalUrl,omitempty"`
+	Stations    []stationStatus `json:"stations"`
+}
+
+// status returns the OCPP runtime status
+func (cs *CS) status() Status {
+	cs.mu.Lock()
+	defer cs.mu.Unlock()
+
+	stations := []stationStatus{}
+
+	for id, reg := range cs.regs {
+		if id == "" {
+			continue // skip anonymous registrations
+		}
+
+		state := StationStatusUnknown
+		if cp := reg.cp; cp != nil {
+			if cp.Connected() {
+				state = StationStatusConnected
+			} else {
+				state = StationStatusConfigured
+			}
+		}
+
+		stations = append(stations, stationStatus{
+			ID:     id,
+			Status: state,
+		})
+	}
+
+	return Status{
+		ExternalUrl: ExternalUrl(),
+		Stations:    stations,
+	}
+}
+
+// SetUpdated sets a callback function that is called when the status changes
+func (cs *CS) SetUpdated(f func()) {
+	cs.mu.Lock()
+	defer cs.mu.Unlock()
+	cs.publishFunc = f
 }
 
 // errorHandler logs error channel
@@ -37,6 +89,13 @@ func (cs *CS) errorHandler(errC <-chan error) {
 	}
 }
 
+// publish triggers the publish callback if set
+func (cs *CS) publish() {
+	if cs.publishFunc != nil {
+		cs.publishFunc()
+	}
+}
+
 func (cs *CS) ChargepointByID(id string) (*CP, error) {
 	cs.mu.Lock()
 	defer cs.mu.Unlock()
@@ -76,6 +135,7 @@ func (cs *CS) RegisterChargepoint(id string, newfun func() *CP, init func(*CP) e
 	}
 
 	cs.mu.Unlock()
+	cs.publish()
 
 	// serialise on chargepoint id
 	reg.setup.Lock()
@@ -112,23 +172,22 @@ func (cs *CS) RegisterChargepoint(id string, newfun func() *CP, init func(*CP) e
 // NewChargePoint implements ocpp16.ChargePointConnectionHandler
 func (cs *CS) NewChargePoint(chargePoint ocpp16.ChargePointConnection) {
 	cs.mu.Lock()
-	defer cs.mu.Unlock()
 
 	// check for configured charge point
 	reg, ok := cs.regs[chargePoint.ID()]
 	if ok {
 		cs.log.DEBUG.Printf("charge point connected: %s", chargePoint.ID())
 
-		// trigger initial connection if charge point is already setup
+		// trigger initial connection
 		if cp := reg.cp; cp != nil {
 			cp.connect(true)
 		}
 
+		cs.mu.Unlock()
+		cs.publish()
 		return
 	}
 
-	cs.log.WARN.Printf("unknown charge point connected: %s", chargePoint.ID())
-
 	// check for configured anonymous charge point
 	reg, ok = cs.regs[""]
 	if ok && reg.cp != nil {
@@ -142,12 +201,17 @@ func (cs *CS) NewChargePoint(chargePoint ocpp16.ChargePointConnection) {
 
 		cp.connect(true)
 
+		cs.mu.Unlock()
+		cs.publish()
 		return
 	}
 
 	// register unknown charge point
-	// when charge point setup is complete, it will eventually be associated with the connected id
 	cs.regs[chargePoint.ID()] = newRegistration()
+	cs.log.WARN.Printf("unknown charge point connected: %s", chargePoint.ID())
+
+	cs.mu.Unlock()
+	cs.publish()
 }
 
 // ChargePointDisconnected implements ocpp16.ChargePointConnectionHandler
@@ -157,4 +221,6 @@ func (cs *CS) ChargePointDisconnected(chargePoint ocpp16.ChargePointConnection)
 	if cp, err := cs.ChargepointByID(chargePoint.ID()); err == nil {
 		cp.connect(false)
 	}
+
+	cs.publish()
 }
diff --git a/charger/ocpp/instance.go b/charger/ocpp/instance.go
index 025a56e5..f7ce50a6 100644
--- a/charger/ocpp/instance.go
+++ b/charger/ocpp/instance.go
@@ -1,7 +1,10 @@
 package ocpp
 
 import (
+	"fmt"
 	"net/http"
+	"net/url"
+	"strings"
 	"sync"
 	"time"
 
@@ -16,11 +19,49 @@ import (
 	"github.com/lorenzodonini/ocpp-go/ws"
 )
 
+type Config struct {
+	Port int `json:"port"`
+}
+
 var (
-	once     sync.Once
-	instance *CS
+	once        sync.Once
+	instance    *CS
+	port        = 8887
+	externalUrl string
 )
 
+// GetStatus returns the OCPP runtime status
+func GetStatus() Status {
+	if instance == nil {
+		return Status{}
+	}
+	return instance.status()
+}
+
+// ExternalUrl returns the auto-generated OCPP external URL based on network external URL
+func ExternalUrl() string {
+	if externalUrl == "" {
+		return ""
+	}
+
+	u, err := url.Parse(externalUrl)
+	if err != nil {
+		return ""
+	}
+
+	// Replace protocol: http -> ws, https -> wss
+	u.Scheme = strings.Replace(u.Scheme, "http", "ws", 1)
+	u.Host = fmt.Sprintf("%s:%d", strings.Split(u.Host, ":")[0], 8887) // deliberately fixed, port configurability only for testing
+
+	return u.String()
+}
+
+// Init initializes the OCPP server
+func Init(cfg Config, networkExternalUrl string) {
+	port = cfg.Port
+	externalUrl = networkExternalUrl
+}
+
 func Instance() *CS {
 	once.Do(func() {
 		log := util.NewLogger("ocpp")
@@ -55,7 +96,7 @@ func Instance() *CS {
 		cs.SetChargePointDisconnectedHandler(instance.ChargePointDisconnected)
 
 		go instance.errorHandler(cs.Errors())
-		go cs.Start(8887, "/{ws}")
+		go cs.Start(port, "/{ws}")
 
 		// wait for server to start
 		for range time.Tick(10 * time.Millisecond) {
diff --git a/charger/ocpp/stationstatus.go b/charger/ocpp/stationstatus.go
new file mode 100644
index 00000000..a0693a06
--- /dev/null
+++ b/charger/ocpp/stationstatus.go
@@ -0,0 +1,10 @@
+package ocpp
+
+type StationStatus int
+
+//go:generate go tool enumer -type StationStatus -trimprefix StationStatus -transform=lower -json
+const (
+	StationStatusUnknown StationStatus = iota
+	StationStatusConfigured
+	StationStatusConnected
+)
diff --git a/charger/ocpp/stationstatus_enumer.go b/charger/ocpp/stationstatus_enumer.go
new file mode 100644
index 00000000..e1b32673
--- /dev/null
+++ b/charger/ocpp/stationstatus_enumer.go
@@ -0,0 +1,100 @@
+// Code generated by "enumer -type StationStatus -trimprefix StationStatus -transform=lower -json"; DO NOT EDIT.
+
+package ocpp
+
+import (
+	"encoding/json"
+	"fmt"
+	"strings"
+)
+
+const _StationStatusName = "unknownconfiguredconnected"
+
+var _StationStatusIndex = [...]uint8{0, 7, 17, 26}
+
+const _StationStatusLowerName = "unknownconfiguredconnected"
+
+func (i StationStatus) String() string {
+	if i < 0 || i >= StationStatus(len(_StationStatusIndex)-1) {
+		return fmt.Sprintf("StationStatus(%d)", i)
+	}
+	return _StationStatusName[_StationStatusIndex[i]:_StationStatusIndex[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 _StationStatusNoOp() {
+	var x [1]struct{}
+	_ = x[StationStatusUnknown-(0)]
+	_ = x[StationStatusConfigured-(1)]
+	_ = x[StationStatusConnected-(2)]
+}
+
+var _StationStatusValues = []StationStatus{StationStatusUnknown, StationStatusConfigured, StationStatusConnected}
+
+var _StationStatusNameToValueMap = map[string]StationStatus{
+	_StationStatusName[0:7]:        StationStatusUnknown,
+	_StationStatusLowerName[0:7]:   StationStatusUnknown,
+	_StationStatusName[7:17]:       StationStatusConfigured,
+	_StationStatusLowerName[7:17]:  StationStatusConfigured,
+	_StationStatusName[17:26]:      StationStatusConnected,
+	_StationStatusLowerName[17:26]: StationStatusConnected,
+}
+
+var _StationStatusNames = []string{
+	_StationStatusName[0:7],
+	_StationStatusName[7:17],
+	_StationStatusName[17:26],
+}
+
+// StationStatusString retrieves an enum value from the enum constants string name.
+// Throws an error if the param is not part of the enum.
+func StationStatusString(s string) (StationStatus, error) {
+	if val, ok := _StationStatusNameToValueMap[s]; ok {
+		return val, nil
+	}
+
+	if val, ok := _StationStatusNameToValueMap[strings.ToLower(s)]; ok {
+		return val, nil
+	}
+	return 0, fmt.Errorf("%s does not belong to StationStatus values", s)
+}
+
+// StationStatusValues returns all values of the enum
+func StationStatusValues() []StationStatus {
+	return _StationStatusValues
+}
+
+// StationStatusStrings returns a slice of all String values of the enum
+func StationStatusStrings() []string {
+	strs := make([]string, len(_StationStatusNames))
+	copy(strs, _StationStatusNames)
+	return strs
+}
+
+// IsAStationStatus returns "true" if the value is listed in the enum definition. "false" otherwise
+func (i StationStatus) IsAStationStatus() bool {
+	for _, v := range _StationStatusValues {
+		if i == v {
+			return true
+		}
+	}
+	return false
+}
+
+// MarshalJSON implements the json.Marshaler interface for StationStatus
+func (i StationStatus) MarshalJSON() ([]byte, error) {
+	return json.Marshal(i.String())
+}
+
+// UnmarshalJSON implements the json.Unmarshaler interface for StationStatus
+func (i *StationStatus) UnmarshalJSON(data []byte) error {
+	var s string
+	if err := json.Unmarshal(data, &s); err != nil {
+		return fmt.Errorf("StationStatus should be a string, got %s", data)
+	}
+
+	var err error
+	*i, err = StationStatusString(s)
+	return err
+}
diff --git a/cmd/root.go b/cmd/root.go
index db9ea881..7960646e 100644
--- a/cmd/root.go
+++ b/cmd/root.go
@@ -13,6 +13,7 @@ import (
 	"time"
 
 	"github.com/evcc-io/evcc/api/globalconfig"
+	"github.com/evcc-io/evcc/charger/ocpp"
 	"github.com/evcc-io/evcc/core"
 	"github.com/evcc-io/evcc/core/keys"
 	hemsapi "github.com/evcc-io/evcc/hems/hems"
@@ -171,6 +172,23 @@ func runRoot(cmd *cobra.Command, args []string) {
 	valueChan := make(chan util.Param, 64)
 	go tee.Run(valueChan)
 
+	// start OCPP server
+	ocppCS := ocpp.Instance()
+	ocppCS.SetUpdated(func() {
+		// republish when OCPP state updates
+		valueChan <- util.Param{Key: keys.Ocpp, Val: struct {
+			Config ocpp.Config `json:"config"`
+			Status ocpp.Status `json:"status"`
+		}{
+			Config: conf.Ocpp,
+			Status: ocpp.GetStatus(),
+		}}
+	})
+	log.INFO.Printf("OCPP local:    ws://127.0.0.1:%d/<stationId>", conf.Ocpp.Port)
+	if ocpp.ExternalUrl() != "" {
+		log.INFO.Printf("OCPP external: %s/<stationId>", ocpp.ExternalUrl())
+	}
+
 	// value cache
 	cache := util.NewParamCache()
 	go cache.Run(pipe.NewDropper(ignoreLogs...).Pipe(tee.Attach()))
@@ -186,7 +204,10 @@ func runRoot(cmd *cobra.Command, args []string) {
 			os.Exit(1)
 		}
 	}()
-	log.INFO.Printf("UI listening at :%d", conf.Network.Port)
+	log.INFO.Printf("UI local:      http://127.0.0.1:%d", conf.Network.Port)
+	if conf.Network.ExternalUrl != "" {
+		log.INFO.Printf("UI external:   %s", conf.Network.ExternalURL())
+	}
 
 	// publish to UI
 	go socketHub.Run(pipe.NewDropper(ignoreEmpty).Pipe(tee.Attach()), cache)
@@ -316,6 +337,13 @@ func runRoot(cmd *cobra.Command, args []string) {
 	valueChan <- util.Param{Key: keys.ModbusProxy, Val: conf.ModbusProxy}
 	valueChan <- util.Param{Key: keys.Mqtt, Val: conf.Mqtt}
 	valueChan <- util.Param{Key: keys.Network, Val: conf.Network}
+	valueChan <- util.Param{Key: keys.Ocpp, Val: struct {
+		Config ocpp.Config `json:"config"`
+		Status ocpp.Status `json:"status"`
+	}{
+		Config: conf.Ocpp,
+		Status: ocpp.GetStatus(),
+	}}
 	valueChan <- util.Param{Key: keys.Sponsor, Val: struct {
 		Status   sponsor.Status `json:"status"`
 		FromYaml bool           `json:"fromYaml"`
diff --git a/cmd/setup.go b/cmd/setup.go
index 898996f8..6e82a013 100644
--- a/cmd/setup.go
+++ b/cmd/setup.go
@@ -17,6 +17,7 @@ import (
 	"github.com/evcc-io/evcc/api"
 	"github.com/evcc-io/evcc/api/globalconfig"
 	"github.com/evcc-io/evcc/charger"
+	"github.com/evcc-io/evcc/charger/ocpp"
 	"github.com/evcc-io/evcc/cmd/shutdown"
 	"github.com/evcc-io/evcc/core"
 	"github.com/evcc-io/evcc/core/circuit"
@@ -65,6 +66,9 @@ var conf = globalconfig.All{
 		Host: "",
 		Port: 7070,
 	},
+	Ocpp: ocpp.Config{
+		Port: 8887,
+	},
 	Mqtt: globalconfig.Mqtt{
 		Topic: "evcc",
 	},
@@ -547,6 +551,11 @@ func configureEnvironment(cmd *cobra.Command, conf *globalconfig.All) error {
 		err = wrapErrorWithClass(ClassMqtt, configureMqtt(&conf.Mqtt))
 	}
 
+	// setup OCPP server
+	if err == nil {
+		configureOCPP(&conf.Ocpp, conf.Network.ExternalUrl)
+	}
+
 	// setup EEBus server
 	if err == nil {
 		err = wrapErrorWithClass(ClassEEBus, configureEEBus(&conf.EEBus))
@@ -758,6 +767,11 @@ func configureMDNS(conf globalconfig.Network) error {
 	return nil
 }
 
+// setup OCPP
+func configureOCPP(cfg *ocpp.Config, externalUrl string) {
+	ocpp.Init(*cfg, externalUrl)
+}
+
 // setup EEBus
 func configureEEBus(conf *eebus.Config) error {
 	// migrate settings
diff --git a/core/keys/global.go b/core/keys/global.go
index c028984c..55ca9bf3 100644
--- a/core/keys/global.go
+++ b/core/keys/global.go
@@ -13,6 +13,7 @@ const (
 	Shm                = "shm"
 	Messaging          = "messaging"
 	ModbusProxy        = "modbusproxy"
+	Ocpp               = "ocpp"
 	Tariffs            = "tariffs"
 	Version            = "version"
 	Config             = "config"
diff --git a/i18n/bg.json b/i18n/bg.json
index f9967545..1601f5d6 100644
--- a/i18n/bg.json
+++ b/i18n/bg.json
@@ -40,7 +40,7 @@
       "chargers": "Зарядни станции",
       "generic": "Общи интеграции",
       "heatingdevices": "Загревателни устройства",
-      "ocppHelp": "Копирай този адрес в конфигурацията на твоите зарядни станции.",
+      "ocppHelp": "Копирайте този URL адрес в конфигурацията на вашето зарядно устройство. Проверете ръководството на производителя за подробности. Зарядното устройство автоматично ще добави своя уникален идентификатор (ID на станцията) към URL адреса. В редки случаи може да се наложи ръчно да посочите идентификатора. Пример: `{url}`",
       "ocppLabel": "Адрес на OCPP сървъра",
       "switchsockets": "Контакти с прекъсвач",
       "template": "Производител",
diff --git a/i18n/cs.json b/i18n/cs.json
index dd795f49..5aaabcc0 100644
--- a/i18n/cs.json
+++ b/i18n/cs.json
@@ -40,7 +40,7 @@
       "chargers": "EV nabíječky",
       "generic": "Obecné integrace",
       "heatingdevices": "Zařízení pro vytápění",
-      "ocppHelp": "Zkopíruj tuto adresu do konfiguračního nástroje nabíječky.",
+      "ocppHelp": "Zkopírujte tuto URL adresu do konfigurace vaší nabíječky. Podrobnosti naleznete v manuálu výrobce. Nabíječka automaticky připojí svůj unikátní identifikátor (ID stanice) k URL. Ve vzácných případech může být nutné identifikátor zadat ručně. Příklad: `{url}`",
       "ocppLabel": "OCPP-Server URL",
       "switchsockets": "Přepínatelné zásuvky",
       "template": "Výrobce",
diff --git a/i18n/da.json b/i18n/da.json
index 1948afde..cbed4e72 100644
--- a/i18n/da.json
+++ b/i18n/da.json
@@ -40,7 +40,7 @@
       "chargers": "EV opladere",
       "generic": "Generisk integration",
       "heatingdevices": "Varmeenheder",
-      "ocppHelp": "Kopier denne adresse til opladerens konfiguration.",
+      "ocppHelp": "Kopier denne URL til konfigurationen af din oplader. Se producentens manual for detaljer. Opladeren vil automatisk tilføje sin unikke identifikator (stations-ID) til URL'en. I sjældne tilfælde skal du muligvis angive identifikatoren manuelt. Eksempel: `{url}`",
       "ocppLabel": "OCPP-Server URL",
       "switchsockets": "Omskiftelige stikkontakter",
       "template": "Fabrikant",
diff --git a/i18n/de.json b/i18n/de.json
index bde27cc7..0c62bc8c 100644
--- a/i18n/de.json
+++ b/i18n/de.json
@@ -58,8 +58,16 @@
       "chargers": "Wallboxen",
       "generic": "Generische Integrationen",
       "heatingdevices": "Wärmeerzeuger",
-      "ocppHelp": "Kopiere diese Adresse in die Konfiguration der Wallbox.",
+      "ocppConfirmContinue": "Deine Wallbox hat sich noch nicht mit evcc verbunden. Möchtest du wirklich fortfahren?",
+      "ocppConnected": "Verbunden!",
+      "ocppDescription": "evcc hat einen eingebauten OCPP-Server. Folge diesen Schritten:",
+      "ocppHelp": "Kopiere diese URL in die Konfiguration deiner Wallbox. Details findest du im Handbuch des Herstellers. Die Wallbox sollte automatisch ihre eindeutige Kennung (Station-ID) an die URL anhängen. In seltenen Fällen musst du die Kennung manuell angeben. Beispiel: `{url}`",
       "ocppLabel": "OCPP-Server URL",
+      "ocppNextStep": "Nächster Schritt",
+      "ocppStep1": "Konfiguriere deine Wallbox, um evcc als OCPP-Server zu verwenden.",
+      "ocppStep2": "Warte darauf, dass deine Wallbox sich mit evcc verbindet.",
+      "ocppStep3": "Fahre fort und schließe die Konfiguration ab.",
+      "ocppWaiting": "Warte auf Verbindung",
       "switchsockets": "Schaltbare Steckdosen",
       "template": "Hersteller",
       "titleAdd": {
@@ -109,10 +117,12 @@
       "chargedEnergy": "Geladen",
       "co2": "Netz-CO₂",
       "configured": "Konfiguriert",
+      "connections": "Verbindungen",
       "controllable": "Steuerbar",
       "currency": "Währung",
       "current": "Strom",
       "currentRange": "Strom",
+      "detected": "Erkannt",
       "dimmed": "Gedimmt",
       "enabled": "Ladebereit",
       "energy": "Energie",
@@ -472,6 +482,23 @@
       "labelPort": "Port",
       "title": "Netzwerk"
     },
+    "ocpp": {
+      "connectedChargers": "Verbundene Wallboxen",
+      "connectionStatus": "Konfigurierte Station-IDs",
+      "connectionStatusHelp": "Verbindungsstatus der konfigurierten Wallboxen.",
+      "detectedChargers": "Erkannte Station-IDs",
+      "detectedHelp": "Diese Wallboxen haben versucht, sich mit evcc zu verbinden. Um eine Wallbox zu verwenden, erstelle einen Ladepunkt mit ihrer Station-ID.",
+      "noChargers": "Keine OCPP-Wallboxen erkannt.",
+      "noStations": "Keine Stationen verbunden",
+      "status": {
+        "configured": "Nicht verbunden",
+        "connected": "Verbunden",
+        "unknown": "Unbekannt"
+      },
+      "title": "OCPP Server",
+      "url": "Server-URL",
+      "urlHelp": "Kopiere diese URL in die Konfiguration deiner Wallbox. Details findest du im Handbuch des Herstellers. Die Wallbox sollte automatisch ihre eindeutige Kennung (Station-ID) an die URL anhängen. In seltenen Fällen musst du die Kennung manuell angeben. Beispiel: `{url}`"
+    },
     "options": {
       "boolean": {
         "no": "nein",
diff --git a/i18n/el.json b/i18n/el.json
index 626dcb98..a6e10664 100644
--- a/i18n/el.json
+++ b/i18n/el.json
@@ -40,7 +40,7 @@
       "chargers": "Φορτιστές οχημάτων",
       "generic": "Γενικές ενσωματώσεις",
       "heatingdevices": "Συσκευές θέρμανσης",
-      "ocppHelp": "Αντιγράψτε αυτή τη διεύθυνση στη διαμόρφωση των φορτιστών σας.",
+      "ocppHelp": "Αντιγράψτε αυτό το URL στη διαμόρφωση του φορτιστή σας. Ελέγξτε το εγχειρίδιο του κατασκευαστή για λεπτομέρειες. Ο φορτιστής θα προσθέσει αυτόματα το μοναδικό αναγνωριστικό του (ID σταθμού) στο URL. Σε σπάνιες περιπτώσεις, ίσως χρειαστεί να καθορίσετε το αναγνωριστικό χειροκίνητα. Παράδειγμα: `{url}`",
       "ocppLabel": "OCPP-Server URL",
       "switchsockets": "Πρίζες με δυνατότητα εναλλαγής",
       "template": "Κατασκευαστής",
diff --git a/i18n/en.json b/i18n/en.json
index dbf47bce..f91ee5c6 100644
--- a/i18n/en.json
+++ b/i18n/en.json
@@ -58,8 +58,16 @@
       "chargers": "EV chargers",
       "generic": "Generic integrations",
       "heatingdevices": "Heating devices",
-      "ocppHelp": "Copy this address into your chargers configuration.",
+      "ocppConfirmContinue": "Your charger has not connected to evcc yet. Are you sure you want to continue?",
+      "ocppConnected": "Connected!",
+      "ocppDescription": "evcc has a built-in OCPP server. Follow these steps:",
+      "ocppHelp": "Copy this URL into your charger's configuration. Check the manufacturer's manual for details. The charger is expected to automatically append its unique identifier (station ID) to the url. In rare cases, you may need to manually specify the identifier. Example: `{url}`",
       "ocppLabel": "OCPP-Server URL",
+      "ocppNextStep": "Next step",
+      "ocppStep1": "Configure your charger to use evcc as OCPP server.",
+      "ocppStep2": "Wait for your charger to connect to evcc.",
+      "ocppStep3": "Proceed and complete the configuration.",
+      "ocppWaiting": "Waiting for connection",
       "switchsockets": "Switchable sockets",
       "template": "Manufacturer",
       "titleAdd": {
@@ -109,10 +117,12 @@
       "chargedEnergy": "Charged",
       "co2": "Grid CO₂",
       "configured": "Configured",
+      "connections": "Connections",
       "controllable": "Controllable",
       "currency": "Currency",
       "current": "Current",
       "currentRange": "Current",
+      "detected": "Detected",
       "dimmed": "Dimmed",
       "enabled": "Enabled",
       "energy": "Energy",
@@ -472,6 +482,23 @@
       "labelPort": "Port",
       "title": "Network"
     },
+    "ocpp": {
+      "connectedChargers": "Connected chargers",
+      "connectionStatus": "Configured station IDs",
+      "connectionStatusHelp": "Connection status of configured chargers.",
+      "detectedChargers": "Detected station IDs",
+      "detectedHelp": "These chargers have tried to connect to evcc. To use a charger, create a loadpoint with its station ID.",
+      "noChargers": "No OCPP chargers detected.",
+      "noStations": "No stations connected",
+      "status": {
+        "configured": "Not connected",
+        "connected": "Connected",
+        "unknown": "Unknown"
+      },
+      "title": "OCPP Server",
+      "url": "Server URL",
+      "urlHelp": "Copy this URL into your charger's configuration. Check the manufacturer's manual for details. The charger is expected to automatically append its unique identifier (station ID) to the url. In rare cases, you may need to manually specify the identifier. Example: `{url}`"
+    },
     "options": {
       "boolean": {
         "no": "no",
diff --git a/i18n/es.json b/i18n/es.json
index b440472c..a766a1b2 100644
--- a/i18n/es.json
+++ b/i18n/es.json
@@ -30,7 +30,7 @@
     "charger": {
       "generic": "Integraciones genéricas",
       "heatingdevices": "Dispositivos de calefacción",
-      "ocppHelp": "Copia esta dirección en la configuración de cargadores.",
+      "ocppHelp": "Copia esta URL en la configuración de tu cargador. Consulta el manual del fabricante para más detalles. El cargador añadirá automáticamente su identificador único (ID de estación) a la URL. En casos excepcionales, es posible que debas especificar el identificador manualmente. Ejemplo: `{url}`",
       "ocppLabel": "URL del servidor OCPP",
       "switchsockets": "Tomas conmutables",
       "template": "Fabricante",
diff --git a/i18n/fi.json b/i18n/fi.json
index 251361e2..ef178051 100644
--- a/i18n/fi.json
+++ b/i18n/fi.json
@@ -40,7 +40,7 @@
       "chargers": "Sähköauton latausasemat",
       "generic": "Yleiset integraatiot",
       "heatingdevices": "Lämmityslaitteet",
-      "ocppHelp": "Kopioi tämä osoite laturien määrityksiin.",
+      "ocppHelp": "Kopioi tämä URL-osoite laturisi asetuksiin. Katso lisätietoja valmistajan käsikirjasta. Laturi lisää automaattisesti yksilöllisen tunnisteensa (aseman tunniste) URL-osoitteeseen. Harvinaisissa tapauksissa saatat joutua määrittämään tunnisteen manuaalisesti. Esimerkki: `{url}`",
       "ocppLabel": "OCPP-palvelimen URL",
       "switchsockets": "Kytkettävät pistorasiat",
       "template": "Valmistaja",
diff --git a/i18n/fr.json b/i18n/fr.json
index 96568dc8..33a9e753 100644
--- a/i18n/fr.json
+++ b/i18n/fr.json
@@ -40,7 +40,7 @@
       "chargers": "Chargeurs VE",
       "generic": "Intégrations génériques",
       "heatingdevices": "Dispositif de chauffage",
-      "ocppHelp": "Copier cette adresse dans votre configuration de chargeurs.",
+      "ocppHelp": "Copiez cette URL dans la configuration de votre chargeur. Consultez le manuel du fabricant pour plus de détails. Le chargeur ajoutera automatiquement son identifiant unique (ID de station) à l'URL. Dans de rares cas, vous devrez peut-être spécifier l'identifiant manuellement. Exemple : `{url}`",
       "ocppLabel": "URL du serveur OCPP",
       "switchsockets": "Prises commutables",
       "template": "Fabricant",
diff --git a/i18n/hr.json b/i18n/hr.json
index 24f33a38..958ed5e4 100644
--- a/i18n/hr.json
+++ b/i18n/hr.json
@@ -40,7 +40,7 @@
       "chargers": "EV punjači",
       "generic": "Opće integracije",
       "heatingdevices": "Uređaji za grijanje",
-      "ocppHelp": "Unesite ovu adresu u konfiguraciju punjača.",
+      "ocppHelp": "Kopirajte ovaj URL u konfiguraciju svog punjača. Provjerite upute proizvođača za detalje. Punjač će automatski dodati svoj jedinstveni identifikator (ID stanice) na URL. U rijetkim slučajevima možda ćete morati ručno odrediti identifikator. Primjer: `{url}`",
       "ocppLabel": "URL OCPP poslužitelja",
       "switchsockets": "Upravljive utičnice",
       "template": "Proizvođač",
diff --git a/i18n/hu.json b/i18n/hu.json
index 9561003e..7af003e4 100644
--- a/i18n/hu.json
+++ b/i18n/hu.json
@@ -40,7 +40,7 @@
       "chargers": "EV töltők",
       "generic": "Általános integrációk",
       "heatingdevices": "Fűtőberendezések",
-      "ocppHelp": "Másolja be ezt a címet a töltő konfigurációjába.",
+      "ocppHelp": "Másolja ezt az URL-t a töltő konfigurációjába. A részletekért tekintse meg a gyártó kézikönyvét. A töltő automatikusan hozzáfűzi egyedi azonosítóját (állomás azonosító) az URL-hez. Ritka esetekben előfordulhat, hogy az azonosítót manuálisan kell megadnia. Példa: `{url}`",
       "ocppLabel": "OCPP-Server URL",
       "switchsockets": "Kapcsolható dugaljak",
       "template": "Gyártó",
diff --git a/i18n/it.json b/i18n/it.json
index 0d7aedb9..3fae0038 100644
--- a/i18n/it.json
+++ b/i18n/it.json
@@ -40,7 +40,7 @@
       "chargers": "Caricatori EV",
       "generic": "Integrazioni generiche",
       "heatingdevices": "Dispositivi di riscaldamento",
-      "ocppHelp": "Copia questo indirizzo nella configurazione dei tuoi caricatori",
+      "ocppHelp": "Copia questo URL nella configurazione del tuo caricatore. Consulta il manuale del produttore per i dettagli. Il caricatore aggiungerà automaticamente il suo identificatore univoco (ID stazione) all'URL. In rari casi, potrebbe essere necessario specificare manualmente l'identificatore. Esempio: `{url}`",
       "ocppLabel": "URL Server OCPP",
       "switchsockets": "Prese commutabili",
       "template": "Produttore",
diff --git a/i18n/lb.json b/i18n/lb.json
index a565be57..91229d3c 100644
--- a/i18n/lb.json
+++ b/i18n/lb.json
@@ -40,7 +40,7 @@
       "chargers": "Wallboxen",
       "generic": "Generesch Integratiounen",
       "heatingdevices": "Heizungsgerät",
-      "ocppHelp": "Kopéier dës Adress an d'Konfiguratioun vun der Wallbox.",
+      "ocppHelp": "Kopéier dës URL an d'Konfiguratioun vun dengem Luedapparat. Kuck am Handbuch vum Hiersteller fir Detailer. De Luedapparat setzt automatesch säin eenzegaartegen Identifikator (Statiounen-ID) un d'URL. A seltenen Fäll muss de vläicht den Identifikator manuell uginn. Beispill: `{url}`",
       "ocppLabel": "URL vum OCPP-Server",
       "switchsockets": "Schaltbar Steckdousen",
       "template": "Hiersteller",
diff --git a/i18n/lt.json b/i18n/lt.json
index 1979d6ce..3990b090 100644
--- a/i18n/lt.json
+++ b/i18n/lt.json
@@ -40,7 +40,7 @@
       "chargers": "Elektromobilių įkrovikliai",
       "generic": "Tipinės integracijos",
       "heatingdevices": "Šildymo prietaisai",
-      "ocppHelp": "Nukopijuokite šį adresą į jūsų įkroviklio nustatymus.",
+      "ocppHelp": "Nukopijuokite šį URL į savo įkroviklio konfigūraciją. Išsamesnės informacijos ieškokite gamintojo vadove. Įkroviklis automatiškai pridės savo unikalų identifikatorių (stoties ID) prie URL. Retais atvejais gali tekti rankiniu būdu nurodyti identifikatorių. Pavyzdys: `{url}`",
       "ocppLabel": "OCPP-Serverio URL",
       "switchsockets": "Išmanios rozetės",
       "template": "Gamintojas",
diff --git a/i18n/nl.json b/i18n/nl.json
index 002e1aeb..4c810a5e 100644
--- a/i18n/nl.json
+++ b/i18n/nl.json
@@ -40,7 +40,7 @@
       "chargers": "EV laders",
       "generic": "Algemene integraties",
       "heatingdevices": "Warmtepompen",
-      "ocppHelp": "Kopieer dit adres in de configuratie van jouw lader.",
+      "ocppHelp": "Kopieer deze URL naar de configuratie van je lader. Raadpleeg de handleiding van de fabrikant voor details. De lader voegt automatisch zijn unieke identificatie (station-ID) toe aan de URL. In zeldzame gevallen moet je de identificatie mogelijk handmatig opgeven. Voorbeeld: `{url}`",
       "ocppLabel": "OCPP-Server URL",
       "switchsockets": "schakelbare stopcontacten",
       "template": "Fabrikant",
diff --git a/i18n/pl.json b/i18n/pl.json
index 528bf1a2..8cac9ecd 100644
--- a/i18n/pl.json
+++ b/i18n/pl.json
@@ -40,7 +40,7 @@
       "chargers": "Ładowarki EV",
       "generic": "Ogólne integracje",
       "heatingdevices": "Urządzenia grzewcze",
-      "ocppHelp": "Skopiuj ten adres do konfiguracji twojej ładowarki.",
+      "ocppHelp": "Skopiuj ten adres URL do konfiguracji swojej ładowarki. Sprawdź instrukcję producenta, aby uzyskać szczegóły. Ładowarka automatycznie doda swój unikalny identyfikator (ID stacji) do adresu URL. W rzadkich przypadkach może być konieczne ręczne określenie identyfikatora. Przykład: `{url}`",
       "ocppLabel": "Adres URL serwera OCPP",
       "switchsockets": "Gniazdka przełączalne",
       "template": "Producent",
diff --git a/i18n/pt.json b/i18n/pt.json
index 174177e7..c3c3e051 100644
--- a/i18n/pt.json
+++ b/i18n/pt.json
@@ -40,7 +40,7 @@
       "chargers": "Carregadores de Veículos Eléctricos",
       "generic": "Integrações genéricas",
       "heatingdevices": "Equipamentos de aquecimento",
-      "ocppHelp": "Copie este endereço para a configuração do seu carregador.",
+      "ocppHelp": "Copie este URL para a configuração do seu carregador. Consulte o manual do fabricante para obter detalhes. O carregador adicionará automaticamente seu identificador único (ID da estação) ao URL. Em casos raros, pode ser necessário especificar o identificador manualmente. Exemplo: `{url}`",
       "ocppLabel": "URL do servidor OCPP",
       "switchsockets": "Tomadas comandadas",
       "template": "Fabricante",
diff --git a/i18n/sl.json b/i18n/sl.json
index 7425365d..f465ca69 100644
--- a/i18n/sl.json
+++ b/i18n/sl.json
@@ -40,7 +40,7 @@
       "chargers": "polnilnice za EV",
       "generic": "Splošne integracije",
       "heatingdevices": "Ogrevalne naprave",
-      "ocppHelp": "Kopirajte ta naslov v konfiguracijo vaše polnilnice.",
+      "ocppHelp": "Kopirajte ta URL v konfiguracijo vašega polnilnika. Za podrobnosti preverite priročnik proizvajalca. Polnilnik bo samodejno dodal svoj edinstveni identifikator (ID postaje) k URL-ju. V redkih primerih boste morda morali identifikator določiti ročno. Primer: `{url}`",
       "ocppLabel": "URL strežnika OCPP",
       "switchsockets": "Preklopne vtičnice",
       "template": "Proizvajalec",
diff --git a/i18n/sv.json b/i18n/sv.json
index 1ff2d801..c2c82663 100644
--- a/i18n/sv.json
+++ b/i18n/sv.json
@@ -40,7 +40,7 @@
       "chargers": "Elbilsladdare",
       "generic": "Generisk integration",
       "heatingdevices": "Värmeenheter",
-      "ocppHelp": "Kopiera in denna adress i din laddkonfiguration.",
+      "ocppHelp": "Kopiera denna URL till din laddares konfiguration. Kontrollera tillverkarens manual för detaljer. Laddaren kommer automatiskt att lägga till sin unika identifierare (stations-ID) till URL:en. I sällsynta fall kan du behöva ange identifieraren manuellt. Exempel: `{url}`",
       "ocppLabel": "OCPP-Server URL",
       "switchsockets": "Byt kontakt",
       "template": "Tillverkare",
diff --git a/i18n/ta.json b/i18n/ta.json
index 6a89a451..f4117f4e 100644
--- a/i18n/ta.json
+++ b/i18n/ta.json
@@ -40,7 +40,7 @@
       "chargers": "ஈ.வி. சார்சர்ச்",
       "generic": "பொதுவான ஒருங்கிணைப்புகள்",
       "heatingdevices": "வெப்ப சாதனங்கள்",
-      "ocppHelp": "இந்த முகவரியை உங்கள் சார்சர்ச் உள்ளமைவில் நகலெடுக்கவும்.",
+      "ocppHelp": "இந்த URL-ஐ உங்கள் சார்ஜரின் உள்ளமைவில் நகலெடுக்கவும். விவரங்களுக்கு உற்பத்தியாளரின் கையேட்டைப் பார்க்கவும். சார்ஜர் தானாகவே அதன் தனித்துவமான அடையாளங்காட்டியை (நிலைய ID) URL-க்கு இணைக்கும். அரிதான சந்தர்ப்பங்களில், நீங்கள் அடையாளங்காட்டியை கைமுறையாக குறிப்பிட வேண்டியிருக்கலாம். எடுத்துக்காட்டு: `{url}`",
       "ocppLabel": "OCPP-SERVER முகவரி",
       "switchsockets": "மாறக்கூடிய சாக்கெட்டுகள்",
       "template": "உற்பத்தியாளர்",
diff --git a/i18n/tr.json b/i18n/tr.json
index d27af8f9..85e837ad 100644
--- a/i18n/tr.json
+++ b/i18n/tr.json
@@ -40,7 +40,7 @@
       "chargers": "Doldurma cihazları",
       "generic": "Genel entegrasyon",
       "heatingdevices": "Isıtma cihazları",
-      "ocppHelp": "Bu adresi doldurma cihazının yapılandırmasına kopyala.",
+      "ocppHelp": "Bu URL'yi şarj cihazınızın yapılandırmasına kopyalayın. Ayrıntılar için üreticinin kılavuzuna bakın. Şarj cihazı, benzersiz tanımlayıcısını (istasyon kimliği) URL'ye otomatik olarak ekleyecektir. Nadir durumlarda tanımlayıcıyı manuel olarak belirtmeniz gerekebilir. Örnek: `{url}`",
       "ocppLabel": "OCPP sunucusu URL'si",
       "switchsockets": "“Açılıp kapanabilen prizler”",
       "template": "Üretici",
diff --git a/i18n/uk.json b/i18n/uk.json
index 3f9ab667..b6907a8e 100644
--- a/i18n/uk.json
+++ b/i18n/uk.json
@@ -40,7 +40,7 @@
       "chargers": "Зарядні пристрої для електромобілів",
       "generic": "Загальні інтеграції",
       "heatingdevices": "Нагрівальні прилади",
-      "ocppHelp": "Скопіюйте цю адресу в конфігурацію зарядних пристроїв.",
+      "ocppHelp": "Скопіюйте цей URL у конфігурацію вашого зарядного пристрою. Перевірте посібник виробника для отримання деталей. Зарядний пристрій автоматично додасть свій унікальний ідентифікатор (ID станції) до URL. У рідкісних випадках може знадобитися вказати ідентифікатор вручну. Приклад: `{url}`",
       "ocppLabel": "URL-адреса OCPP-сервера",
       "switchsockets": "Перемикаються розетки",
       "template": "Виробник",
diff --git a/i18n/zh-Hans.json b/i18n/zh-Hans.json
index 73e8400b..33d9d2f5 100644
--- a/i18n/zh-Hans.json
+++ b/i18n/zh-Hans.json
@@ -40,7 +40,7 @@
       "chargers": "电动汽车充电桩",
       "generic": "通用集成",
       "heatingdevices": "加热设备",
-      "ocppHelp": "将此地址复制到您的充电桩配置中。",
+      "ocppHelp": "将此URL复制到您的充电器配置中。详细信息请查看制造商的手册。充电器将自动将其唯一标识符（站点ID）附加到URL。在极少数情况下，您可能需要手动指定标识符。示例：`{url}`",
       "ocppLabel": "OCPP 服务器 URL",
       "switchsockets": "可切换插座",
       "template": "制造商",
diff --git a/package-lock.json b/package-lock.json
index 7cb16ecb..f65709d1 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -42,6 +42,7 @@
         "@types/kill-port": "^2.0.3",
         "@types/smoothscroll-polyfill": "^0.3.4",
         "@types/wait-on": "^5.3.4",
+        "@types/ws": "^8.18.1",
         "@vitejs/plugin-legacy": "^7.2.1",
         "@vitejs/plugin-vue": "^6.0.2",
         "@vue/compiler-sfc": "^3.2.30",
@@ -78,7 +79,8 @@
         "vue-eslint-parser": "^10.2.0",
         "vue-hot-reload-api": "^2.3.4",
         "vue-tsc": "^3.1.5",
-        "wait-on": "^9.0.1"
+        "wait-on": "^9.0.1",
+        "ws": "^8.18.3"
       },
       "engines": {
         "node": ">=22.0.0",
diff --git a/package.json b/package.json
index dd24a1fa..b133c4cd 100644
--- a/package.json
+++ b/package.json
@@ -63,6 +63,7 @@
     "@types/kill-port": "^2.0.3",
     "@types/smoothscroll-polyfill": "^0.3.4",
     "@types/wait-on": "^5.3.4",
+    "@types/ws": "^8.18.1",
     "@vitejs/plugin-legacy": "^7.2.1",
     "@vitejs/plugin-vue": "^6.0.2",
     "@vue/compiler-sfc": "^3.2.30",
@@ -99,6 +100,7 @@
     "vue-eslint-parser": "^10.2.0",
     "vue-hot-reload-api": "^2.3.4",
     "vue-tsc": "^3.1.5",
-    "wait-on": "^9.0.1"
+    "wait-on": "^9.0.1",
+    "ws": "^8.18.3"
   }
 }
diff --git a/server/db/db.go b/server/db/db.go
index 8df10141..19b5011f 100644
--- a/server/db/db.go
+++ b/server/db/db.go
@@ -22,21 +22,44 @@ func New(driver, dsn string) (*gorm.DB, error) {
 
 	switch driver {
 	case "sqlite":
-		file, err := homedir.Expand(dsn)
+
+		// Example DSNs:
+		//"path/to/database.db"
+		// "~/database.db",
+		// "database.db?cache=shared&journal_mode=WAL"
+		// ":memory:"
+
+		// Split database path and connection parameters
+		dbPath, connectionParams, _ := strings.Cut(dsn, "?")
+
+		file, err := homedir.Expand(dbPath)
 		if err != nil {
 			return nil, err
 		}
 
-		// Store the expanded file path for later use
-		FilePath = file
 		if err := os.MkdirAll(filepath.Dir(file), 0700); err != nil {
 			return nil, err
 		}
 
-		util.NewLogger("main").INFO.Println("using sqlite database:", file)
+		// Store the expanded file path for later use
+		FilePath = file
+
+		// Add busy_timeout pragma if not already present
+		if !strings.Contains(connectionParams, "_pragma=busy_timeout") {
+			// Append '&' if there are existing connection parameters
+			if len(connectionParams) > 0 {
+				connectionParams += "&"
+			}
+
+			// Add busy_timeout pragma to connection parameters
+			connectionParams += "_pragma=busy_timeout(5000)"
+		}
+
+		connectionStr := file + "?" + connectionParams
+
+		util.NewLogger("main").INFO.Println("using sqlite database:", connectionStr)
 
-		// avoid busy errors
-		dialect = sqlite.Open(file + "?_pragma=busy_timeout(5000)")
+		dialect = sqlite.Open(connectionStr)
 	// case "postgres":
 	// 	dialect = postgres.Open(dsn)
 	// case "mysql":
diff --git a/util/templates/defaults.yaml b/util/templates/defaults.yaml
index 45b1870e..6de60dc4 100644
--- a/util/templates/defaults.yaml
+++ b/util/templates/defaults.yaml
@@ -596,8 +596,8 @@ presets:
       description:
         generic: Station ID
       help:
-        en: "Station ID of the charging point. Note: In exceptional cases, it may be necessary to manually append this ID to the OCPP URL of the charging station in the form `ws://<evcc-address>:8887/<stationid>`. Most charging stations automatically add the ID internally."
-        de: "Station ID des Ladepunktes. Hinweis: In Ausnahmefällen kann es erforderlich sein, diese ID manuell an die OCPP-URL der Ladestation in der Form `ws://<evcc-adresse>:8887/<stationid>` anzuhängen. Die meisten Ladestationen fügen die ID intern automatisch hinzu."
+        en: Unique identifier of the charger. evcc auto-detects this once the charger connects.
+        de: Eindeutige Wallbox-Kennung. evcc erkennt diese automatisch, sobald sich die Wallbox verbindet.
       example: EVB-P12354
     - name: connector
       description:
diff --git a/vehicle/bluelink.go b/vehicle/bluelink.go
index 91f1c470..22c55f05 100644
--- a/vehicle/bluelink.go
+++ b/vehicle/bluelink.go
@@ -96,8 +96,23 @@ func newBluelinkFromConfig(brand string, other map[string]any, settings bluelink
 
 	v := &Bluelink{
 		embed:    &cc.embed,
-		Provider: bluelink.NewProvider(api, vehicle, cc.Expiry, cc.Cache),
+		Provider: bluelink.NewProvider(api, vehicle, cc.Cache),
 	}
 
+	// Try to fetch battery capacity from vehicle if the user didn't provide one in the configuration
+	var capacity float64 = 0
+	if v.Capacity_ == 0 {
+		capacity = fetchVehicleCapacity(api, vehicle, capacity)
+	}
+
+	v.fromVehicle(vehicle.VehicleName, capacity)
+
 	return v, nil
 }
+
+func fetchVehicleCapacity(api *bluelink.API, vehicle bluelink.Vehicle, capacity float64) float64 {
+	if res, err := api.StatusLatest(vehicle); err == nil {
+		capacity, _ = res.Capacity()
+	}
+	return capacity
+}
diff --git a/vehicle/bluelink/api.go b/vehicle/bluelink/api.go
index dd356013..bf73deaa 100644
--- a/vehicle/bluelink/api.go
+++ b/vehicle/bluelink/api.go
@@ -14,10 +14,9 @@ import (
 
 const (
 	VehiclesURL         = "vehicles"
-	StatusURL           = "vehicles/%s/status"
-	StatusLatestURL     = "vehicles/%s/status/latest"
-	StatusURLCCS2       = "vehicles/%s/ccs2/carstatus"
-	StatusLatestURLCCS2 = "vehicles/%s/ccs2/carstatus/latest"
+	StatusURL           = "vehicles/%s/status"                // Triggers refresh from vehicle (older API)
+	StatusLatestURL     = "vehicles/%s/status/latest"         // Cached data with location/odometer (older API)
+	StatusLatestURLCCS2 = "vehicles/%s/ccs2/carstatus/latest" // Newer API (2024+ vehicles with ccOS)
 )
 
 const (
@@ -69,7 +68,7 @@ func (v *API) Vehicles() ([]Vehicle, error) {
 	return res.ResMsg.Vehicles, err
 }
 
-// StatusLatest retrieves the latest server-side status
+// StatusLatest retrieves vehicle status (triggers refresh for older API, then returns cached data)
 func (v *API) StatusLatest(vehicle Vehicle) (BluelinkVehicleStatusLatest, error) {
 	vid := vehicle.VehicleID
 
@@ -83,6 +82,9 @@ func (v *API) StatusLatest(vehicle Vehicle) (BluelinkVehicleStatusLatest, error)
 		return res, err
 	}
 
+	// For older API: first trigger refresh, then get latest cached data
+	_ = v.Refresh(vehicle) // Ignore error, will retry with /status/latest
+
 	var res StatusLatestResponse
 	uri := fmt.Sprintf("%s/%s", v.baseURI, fmt.Sprintf(StatusLatestURL, vid))
 	err := v.GetJSON(uri, &res)
@@ -92,25 +94,13 @@ func (v *API) StatusLatest(vehicle Vehicle) (BluelinkVehicleStatusLatest, error)
 	return res, err
 }
 
-// StatusPartial refreshes the status
-func (v *API) StatusPartial(vehicle Vehicle) (BluelinkVehicleStatus, error) {
-	vid := vehicle.VehicleID
-
-	if vehicle.CcuCCS2ProtocolSupport != 0 {
-		var res StatusLatestResponseCCS
-		uri := fmt.Sprintf("%s/%s", v.baseURI, fmt.Sprintf(StatusLatestURLCCS2, vid))
-		err := v.GetJSON(uri, &res)
-		if err == nil && res.RetCode != resOK {
-			err = fmt.Errorf("unexpected response: %s", res.RetCode)
-		}
-		return res, err
-	}
-
+// Refresh triggers a status update from the vehicle
+func (v *API) Refresh(vehicle Vehicle) error {
 	var res StatusResponse
-	uri := fmt.Sprintf("%s/%s", v.baseURI, fmt.Sprintf(StatusURL, vid))
+	uri := fmt.Sprintf("%s/%s", v.baseURI, fmt.Sprintf(StatusURL, vehicle.VehicleID))
 	err := v.GetJSON(uri, &res)
 	if err == nil && res.RetCode != resOK {
 		err = fmt.Errorf("unexpected response: %s", res.RetCode)
 	}
-	return res.ResMsg, err
+	return err
 }
diff --git a/vehicle/bluelink/provider.go b/vehicle/bluelink/provider.go
index 81b916b2..3009f4b3 100644
--- a/vehicle/bluelink/provider.go
+++ b/vehicle/bluelink/provider.go
@@ -7,88 +7,22 @@ import (
 	"github.com/evcc-io/evcc/util"
 )
 
-const refreshTimeout = 2 * time.Minute
-
 // Provider implements the vehicle api.
 // Based on https://github.com/Hacksore/bluelinky.
 type Provider struct {
-	statusG     func() (BluelinkVehicleStatus, error)
-	statusLG    func() (BluelinkVehicleStatusLatest, error)
-	refreshG    func() (BluelinkVehicleStatus, error)
-	expiry      time.Duration
-	refreshTime time.Time
+	statusG func() (BluelinkVehicleStatusLatest, error)
+	refresh func() error
 }
 
-// New creates a new BlueLink API
-func NewProvider(api *API, vehicle Vehicle, expiry, cache time.Duration) *Provider {
-	v := &Provider{
-		refreshG: func() (BluelinkVehicleStatus, error) {
-			return api.StatusPartial(vehicle)
-		},
-		expiry: expiry,
-	}
-
-	v.statusG = util.Cached(func() (BluelinkVehicleStatus, error) {
-		return v.status(
-			func() (BluelinkVehicleStatusLatest, error) { return api.StatusLatest(vehicle) },
-		)
-	}, cache)
-
-	v.statusLG = util.Cached(func() (BluelinkVehicleStatusLatest, error) {
-		return api.StatusLatest(vehicle)
-	}, cache)
-
-	return v
-}
-
-// status wraps the api status call and adds status refresh
-func (v *Provider) status(statusG func() (BluelinkVehicleStatusLatest, error)) (BluelinkVehicleStatus, error) {
-	res, err := statusG()
-
-	var ts time.Time
-	if err == nil {
-		ts, err = res.BluelinkVehicleStatus().Updated()
-		if err != nil {
-			return res.BluelinkVehicleStatus(), err
-		}
-
-		// return the current value
-		if time.Since(ts) <= v.expiry {
-			v.refreshTime = time.Time{}
-			return res.BluelinkVehicleStatus(), nil
-		}
-	}
-
-	// request a refresh, irrespective of a previous error
-	if v.refreshTime.IsZero() {
-		v.refreshTime = time.Now()
-
-		// TODO async refresh
-		res, err := v.refreshG()
-		if err == nil {
-			if ts, err = res.Updated(); err == nil && time.Since(ts) <= v.expiry {
-				v.refreshTime = time.Time{}
-				return res, nil
-			}
-
-			err = api.ErrMustRetry
-		}
-
-		return nil, err
-	}
-
-	// refresh finally expired
-	if time.Since(v.refreshTime) > refreshTimeout {
-		v.refreshTime = time.Time{}
-		if err == nil {
-			err = api.ErrTimeout
-		}
-	} else {
-		// wait for refresh, irrespective of a previous error
-		err = api.ErrMustRetry
+// NewProvider creates a new BlueLink API
+func NewProvider(api *API, vehicle Vehicle, cache time.Duration) *Provider {
+	impl := &Provider{
+		statusG: util.Cached(func() (BluelinkVehicleStatusLatest, error) {
+			return api.StatusLatest(vehicle)
+		}, cache),
+		refresh: func() error { return api.Refresh(vehicle) },
 	}
-
-	return nil, err
+	return impl
 }
 
 var _ api.Battery = (*Provider)(nil)
@@ -104,7 +38,7 @@ func (v *Provider) Soc() (float64, error) {
 
 var _ api.ChargeState = (*Provider)(nil)
 
-// Status implements the api.Battery interface
+// Status implements the api.ChargeState interface
 func (v *Provider) Status() (api.ChargeStatus, error) {
 	status := api.StatusNone
 	res, err := v.statusG()
@@ -138,9 +72,9 @@ func (v *Provider) Range() (int64, error) {
 
 var _ api.VehicleOdometer = (*Provider)(nil)
 
-// Range implements the api.VehicleRange interface
+// Odometer implements the api.VehicleOdometer interface
 func (v *Provider) Odometer() (float64, error) {
-	res, err := v.statusLG()
+	res, err := v.statusG()
 	if err != nil {
 		return 0, err
 	}
@@ -173,7 +107,7 @@ var _ api.VehiclePosition = (*Provider)(nil)
 
 // Position implements the api.VehiclePosition interface
 func (v *Provider) Position() (float64, float64, error) {
-	res, err := v.statusLG()
+	res, err := v.statusG()
 	if err != nil {
 		return 0, 0, err
 	}
@@ -184,7 +118,6 @@ var _ api.Resurrector = (*Provider)(nil)
 
 // WakeUp implements the api.Resurrector interface
 func (v *Provider) WakeUp() error {
-	// forcing an update will usually make the car start charging even if the (first) resulting status still says it does not charge...
-	_, err := v.refreshG()
-	return err
+	// Triggers refresh from vehicle
+	return v.refresh()
 }
diff --git a/vehicle/bluelink/types.go b/vehicle/bluelink/types.go
index ada9edb5..4153cb24 100644
--- a/vehicle/bluelink/types.go
+++ b/vehicle/bluelink/types.go
@@ -7,7 +7,7 @@ import (
 	"github.com/evcc-io/evcc/api"
 )
 
-type BluelinkVehicleStatus interface {
+type BluelinkVehicleStatusLatest interface {
 	Updated() (time.Time, error)
 	SoC() (float64, error)
 	Status() (api.ChargeStatus, error)
@@ -15,12 +15,9 @@ type BluelinkVehicleStatus interface {
 	Range() (int64, error)
 	Climater() (bool, error)
 	GetLimitSoc() (int64, error)
-}
-
-type BluelinkVehicleStatusLatest interface {
-	BluelinkVehicleStatus() BluelinkVehicleStatus
 	Odometer() (float64, error)
 	Position() (float64, float64, error)
+	Capacity() (float64, error)
 }
 
 type VehiclesResponse struct {
@@ -33,7 +30,7 @@ type VehiclesResponse struct {
 type StatusResponse struct {
 	RetCode string
 	ResCode string
-	ResMsg  VehicleStatus
+	// ResMsg is intentionally omitted - structure varies between old/CCS2 models
 }
 
 type StatusLatestResponse struct {
@@ -168,8 +165,32 @@ func (d VehicleStatus) GetLimitSoc() (int64, error) {
 	return 0, api.ErrNotAvailable
 }
 
-func (d StatusLatestResponse) BluelinkVehicleStatus() BluelinkVehicleStatus {
-	return d.ResMsg.VehicleStatusInfo.VehicleStatus
+func (d StatusLatestResponse) Updated() (time.Time, error) {
+	return d.ResMsg.VehicleStatusInfo.VehicleStatus.Updated()
+}
+
+func (d StatusLatestResponse) SoC() (float64, error) {
+	return d.ResMsg.VehicleStatusInfo.VehicleStatus.SoC()
+}
+
+func (d StatusLatestResponse) Status() (api.ChargeStatus, error) {
+	return d.ResMsg.VehicleStatusInfo.VehicleStatus.Status()
+}
+
+func (d StatusLatestResponse) FinishTime() (time.Time, error) {
+	return d.ResMsg.VehicleStatusInfo.VehicleStatus.FinishTime()
+}
+
+func (d StatusLatestResponse) Range() (int64, error) {
+	return d.ResMsg.VehicleStatusInfo.VehicleStatus.Range()
+}
+
+func (d StatusLatestResponse) Climater() (bool, error) {
+	return d.ResMsg.VehicleStatusInfo.VehicleStatus.Climater()
+}
+
+func (d StatusLatestResponse) GetLimitSoc() (int64, error) {
+	return d.ResMsg.VehicleStatusInfo.VehicleStatus.GetLimitSoc()
 }
 
 func (d StatusLatestResponse) Odometer() (float64, error) {
@@ -187,6 +208,10 @@ func (d StatusLatestResponse) Position() (float64, float64, error) {
 	return 0, 0, api.ErrNotAvailable
 }
 
+func (d StatusLatestResponse) Capacity() (float64, error) {
+	return 0, api.ErrNotAvailable
+}
+
 type StatusLatestResponseCCS struct {
 	RetCode string
 	ResCode string
@@ -279,10 +304,6 @@ type VehicleStatusCCS struct {
 	}
 }
 
-func (d StatusLatestResponseCCS) BluelinkVehicleStatus() BluelinkVehicleStatus {
-	return d
-}
-
 func (d StatusLatestResponseCCS) Updated() (time.Time, error) {
 	epoch, err := strconv.ParseInt(d.ResMsg.LastUpdateTime, 10, 64)
 	if err != nil {
@@ -351,3 +372,14 @@ func (d StatusLatestResponseCCS) Position() (float64, float64, error) {
 	}
 	return 0, 0, api.ErrNotAvailable
 }
+
+func (d StatusLatestResponseCCS) Capacity() (float64, error) {
+	if d.ResMsg.State.Vehicle.Green != nil {
+		// Convert from Kilo Joules to kWh: 1 kWh = 3,600kJ
+		capacityKWh := d.ResMsg.State.Vehicle.Green.BatteryManagement.BatteryCapacity.Value / 3600
+		if capacityKWh > 0 {
+			return capacityKWh, nil
+		}
+	}
+	return 0, api.ErrNotAvailable
+}



Test patch:
diff --git a/charger/ocpp/instance_test.go b/charger/ocpp/instance_test.go
new file mode 100644
index 00000000..9f8a62d7
--- /dev/null
+++ b/charger/ocpp/instance_test.go
@@ -0,0 +1,27 @@
+package ocpp
+
+import (
+	"testing"
+)
+
+func TestExternalUrl(t *testing.T) {
+	tests := []struct{ input, expected string }{
+		{"", ""},
+		{"http://example.com:7070", "ws://example.com:8887"},
+		{"https://example.com:443", "wss://example.com:8887"},
+		{"http://example.com", "ws://example.com:8887"},
+		{"https://example.com", "wss://example.com:8887"},
+		{"http://10.20.30.40:7070/path", "ws://10.20.30.40:8887/path"},
+		{"https://example.com/path", "wss://example.com:8887/path"},
+		{"ws://example.com", "ws://example.com:8887"},
+		{"wss://example.com:9000", "wss://example.com:8887"},
+		{"strange://example.com", "strange://example.com:8887"},
+	}
+
+	for _, tt := range tests {
+		externalUrl = tt.input
+		if result := ExternalUrl(); result != tt.expected {
+			t.Errorf("ExternalUrl(%q) = %q, want %q", tt.input, result, tt.expected)
+		}
+	}
+}
diff --git a/server/db/db_test.go b/server/db/db_test.go
new file mode 100644
index 00000000..4695cf2b
--- /dev/null
+++ b/server/db/db_test.go
@@ -0,0 +1,65 @@
+package db
+
+import (
+	"testing"
+
+	"github.com/stretchr/testify/assert"
+)
+
+func TestUnitNewDriver(t *testing.T) {
+	tmpDir := t.TempDir()
+
+	tests := []struct {
+		name             string
+		driver           string
+		dsn              string
+		expectedFilePath string
+		wantErr          bool
+	}{
+		{
+			name:             "SQLite In-Memory",
+			driver:           "sqlite",
+			dsn:              ":memory:",
+			expectedFilePath: ":memory:",
+			wantErr:          false,
+		},
+		{
+			name:             "SQLite File",
+			driver:           "sqlite",
+			dsn:              tmpDir + "/evcc.db",
+			expectedFilePath: tmpDir + "/evcc.db",
+			wantErr:          false,
+		},
+		{
+			name:             "SQLite with connection parameters",
+			driver:           "sqlite",
+			dsn:              tmpDir + "evcc.db?_pragma=busy_timeout(5000)&_pragma=journal_mode(WAL)",
+			wantErr:          false,
+			expectedFilePath: tmpDir + "evcc.db",
+		},
+		{
+			name:    "Unsupported Driver",
+			driver:  "postgresql",
+			dsn:     "/var/lib/evcc/evcc.db",
+			wantErr: true,
+		},
+	}
+
+	for _, test := range tests {
+		t.Run(test.name, func(t *testing.T) {
+			// Reset file path
+			FilePath = ""
+
+			driver, err := New(test.driver, test.dsn)
+			if test.wantErr {
+				assert.Error(t, err)
+				assert.Nil(t, driver)
+			} else {
+				assert.NoError(t, err)
+				assert.NotNil(t, driver)
+			}
+
+			assert.Equal(t, test.expectedFilePath, FilePath)
+		})
+	}
+}
diff --git a/tests/config-loadpoint.spec.ts b/tests/config-loadpoint.spec.ts
index 1ba858fc..2a3a8405 100644
--- a/tests/config-loadpoint.spec.ts
+++ b/tests/config-loadpoint.spec.ts
@@ -664,12 +664,12 @@ test.describe("sponsor token", () => {
     await page.goto("/#/config");
     await enableExperimental(page);
 
-    // add loadpoint with OCPP charger
-    await newLoadpoint(page, "OCPP Test Charger");
+    // add loadpoint with Peblar charger
+    await newLoadpoint(page, "Peblar Test Charger");
     await page.getByTestId("loadpoint-modal").getByRole("button", { name: "Add charger" }).click();
     const chargerModal = page.getByTestId("charger-modal");
     await expectModalVisible(chargerModal);
-    await chargerModal.getByLabel("Manufacturer").selectOption({ label: "OCPP 1.6J compatible" });
+    await chargerModal.getByLabel("Manufacturer").selectOption({ label: "Peblar Business" });
 
     // verify disabled save button
     await expect(chargerModal.getByRole("button", { name: "Save" })).toBeDisabled();
diff --git a/tests/config-ocpp.spec.ts b/tests/config-ocpp.spec.ts
new file mode 100644
index 00000000..e435bdd2
--- /dev/null
+++ b/tests/config-ocpp.spec.ts
@@ -0,0 +1,97 @@
+import { test, expect } from "@playwright/test";
+import { start, stop, baseUrl } from "./evcc";
+import { startSimulator, stopSimulator, simulatorUrl } from "./simulator";
+import { expectModalVisible, enableExperimental } from "./utils";
+import axios from "axios";
+
+test.use({ baseURL: baseUrl() });
+
+const OCPP_STATION_ID = "test-station-001";
+
+test.beforeEach(async () => {
+  await startSimulator();
+  await start();
+});
+
+test.afterEach(async () => {
+  await stop();
+  await stopSimulator();
+});
+
+test.describe("ocpp", () => {
+  test("ocpp modal", async ({ page }) => {
+    // Navigate to config page
+    await page.goto("/#/config");
+    await enableExperimental(page, true);
+
+    // Open OCPP card
+    const ocppCard = page.getByTestId("ocpp");
+    await expect(ocppCard).toContainText(["Configured", "no"].join(""));
+    await ocppCard.getByRole("button", { name: "edit" }).click();
+
+    const ocppModal = page.getByTestId("ocpp-modal");
+    await expectModalVisible(ocppModal);
+    const serverUrl = await ocppModal.getByLabel("Server URL").inputValue();
+    await expect(ocppModal).toContainText("No OCPP chargers detected.");
+
+    // Connect OCPP client via simulator UI
+    await page.goto(simulatorUrl());
+
+    // Fill in OCPP connection details
+    const addClientCard = page.getByTestId("ocpp-add-client");
+    await addClientCard.getByLabel("Server URL").fill(serverUrl);
+    await addClientCard.getByLabel("Station ID").fill(OCPP_STATION_ID);
+    await addClientCard.getByRole("button", { name: "Connect" }).click();
+
+    // Navigate back to evcc config page
+    await page.goto("/#/config");
+    await expect(ocppCard).toContainText(["Connections", "0/0", "Detected", "1"].join(""));
+    await ocppCard.getByRole("button", { name: "edit" }).click();
+    await expectModalVisible(ocppModal);
+    await expect(ocppModal).toContainText("Detected station IDs");
+    await expect(ocppModal).toContainText([OCPP_STATION_ID, "Unknown"].join(""));
+    await expect(ocppModal).not.toContainText("No OCPP chargers detected.");
+  });
+
+  test("create ocpp charger", async ({ page }) => {
+    await page.goto("/#/config");
+    await enableExperimental(page, true);
+
+    // Open loadpoint modal and select charging point type
+    await page.getByRole("button", { name: "Add charger or heater" }).click();
+    const lpModal = page.getByTestId("loadpoint-modal");
+    await expectModalVisible(lpModal);
+    await lpModal.getByRole("button", { name: "Charging point" }).click();
+    await lpModal.getByLabel("Title").fill("OCPP Test Charger");
+
+    // Open charger modal and select OCPP 1.6J
+    await lpModal.getByRole("button", { name: "Add charger" }).click();
+    const chargerModal = page.getByTestId("charger-modal");
+    await expectModalVisible(chargerModal);
+    await chargerModal.getByLabel("Manufacturer").selectOption("OCPP 1.6J compatible");
+
+    // Verify waiting for connection state and extract server URL
+    const serverUrl = await chargerModal.getByLabel("OCPP-Server URL").inputValue();
+    const waitingButton = chargerModal.getByRole("button", { name: "Waiting for connection" });
+    await expect(waitingButton).toBeVisible();
+
+    // Connect OCPP client via simulator REST API
+    await axios.post(`${simulatorUrl()}/api/ocpp/connect`, {
+      stationId: OCPP_STATION_ID,
+      serverUrl: serverUrl,
+    });
+
+    // Verify connection successful
+    await expect(chargerModal).toContainText("Connected!");
+    await expect(waitingButton).not.toBeVisible();
+
+    // Proceed to validation step
+    await chargerModal.getByRole("button", { name: "Next step" }).click();
+    await expect(chargerModal.getByLabel("Station ID")).toHaveValue(OCPP_STATION_ID);
+
+    // Validate and verify sponsor token error
+    const testResult = chargerModal.getByTestId("test-result");
+    await testResult.getByRole("link", { name: "Validate" }).click();
+    await expect(testResult).toContainText("No sponsor token configured.");
+  });
+});
diff --git a/tests/evcc.ts b/tests/evcc.ts
index 9af5bc45..71ce7f19 100644
--- a/tests/evcc.ts
+++ b/tests/evcc.ts
@@ -20,6 +20,11 @@ function workerPort() {
   return 11000 + index;
 }
 
+function ocppPort() {
+  const index = Number(process.env["TEST_WORKER_INDEX"] ?? 0);
+  return 12000 + index;
+}
+
 function logPrefix() {
   return `[worker:${process.env["TEST_WORKER_INDEX"]}]`;
 }
@@ -99,14 +104,20 @@ async function _restoreDatabase(sqlDumps: string) {
 async function _start(config?: string, flags: string | string[] = []) {
   const configArgs = config ? ["--config", config.includes("/") ? config : `tests/${config}`] : [];
   const port = workerPort();
+  const ocpp = ocppPort();
   log(`wait until port ${port} is available`);
   // wait for port to be available
   await waitOn({ resources: [`tcp:${port}`], reverse: true, log: LOG_ENABLED });
+  log(`starting evcc on ports ${port} (HTTP) and ${ocpp} (OCPP)`);
   const additionalFlags = typeof flags === "string" ? [flags] : flags;
   additionalFlags.push("--log", "debug,httpd:trace");
-  log("starting evcc", { config, port, additionalFlags });
+  log("starting evcc", { config, port, ocpp, additionalFlags });
   const instance = spawn(BINARY, [...configArgs, ...additionalFlags], {
-    env: { EVCC_NETWORK_PORT: port.toString(), EVCC_DATABASE_DSN: dbPath() },
+    env: {
+      EVCC_NETWORK_PORT: port.toString(),
+      EVCC_OCPP_PORT: ocpp.toString(),
+      EVCC_DATABASE_DSN: dbPath(),
+    },
     stdio: ["pipe", "pipe", "pipe"],
   });
   const steamLog = createSteamLog();
@@ -137,7 +148,7 @@ async function _stop(instance?: ChildProcess) {
   if (instance) {
     log("shutting down evcc hard", { port });
     instance.kill("SIGKILL");
-    await waitOn({ resources: [`tcp:${port}`], reverse: true, log: LOG_ENABLED });
+    await waitOn({ resources: [`tcp:${port}`], reverse: true, log: LOG_ENABLED, timeout: 5000 });
     log("evcc is down", { port });
     return;
   }
@@ -165,7 +176,7 @@ async function _stop(instance?: ChildProcess) {
     }
   }
   log(`wait until port ${port} is closed`);
-  await waitOn({ resources: [`tcp:${port}`], reverse: true, log: LOG_ENABLED });
+  await waitOn({ resources: [`tcp:${port}`], reverse: true, log: LOG_ENABLED, timeout: 5000 });
   log("evcc is down", { port });
 }
 
diff --git a/tests/logs.spec.ts b/tests/logs.spec.ts
index 35df7fd2..e878435c 100644
--- a/tests/logs.spec.ts
+++ b/tests/logs.spec.ts
@@ -47,7 +47,7 @@ test.describe("opening logs", async () => {
 test.describe("features", async () => {
   test("content", async ({ page }) => {
     await page.goto("/#/log");
-    await page.getByTestId("log-search").fill("listening at");
-    await expect(page.getByTestId("log-content")).toContainText("listening at");
+    await page.getByTestId("log-search").fill("UI local");
+    await expect(page.getByTestId("log-content")).toContainText("UI local");
   });
 });
diff --git a/tests/simulator.ts b/tests/simulator.ts
index a990b639..bdc0078a 100644
--- a/tests/simulator.ts
+++ b/tests/simulator.ts
@@ -11,7 +11,7 @@ const LOG_ENABLED = !process.env["GITHUB_ACTIONS"];
 
 function workerPort() {
   const index = parseInt(process.env["TEST_WORKER_INDEX"] ?? "-1");
-  return 12000 + index;
+  return 13000 + index;
 }
 
 function logPrefix() {
diff --git a/tests/simulator/api.ts b/tests/simulator/api.ts
index 16cb4cb0..4938b89b 100644
--- a/tests/simulator/api.ts
+++ b/tests/simulator/api.ts
@@ -1,6 +1,9 @@
 import bodyParser from "body-parser";
 import type { Connect, ViteDevServer } from "vite";
 import type { ServerResponse } from "http";
+import { OcppClient } from "./ocppClient";
+
+const ocppClients = new Map<string, OcppClient>();
 
 let state = {
   site: {
@@ -11,6 +14,9 @@ let state = {
   loadpoints: [{ power: 0, energy: 0, enabled: false, status: "A" }],
   vehicles: [{ soc: 0, range: 0 }],
   hems: { relay: false },
+  ocpp: {
+    clients: [] as { stationId: string; serverUrl: string; connected: boolean }[],
+  },
 };
 
 const loggingMiddleware = (
@@ -34,6 +40,10 @@ const stateApiMiddleware = (
     res.end();
   } else if (req.method === "POST" && req.originalUrl === "/api/shutdown") {
     console.log("[simulator] POST /api/shutdown");
+    for (const client of ocppClients.values()) {
+      client.disconnect();
+    }
+    ocppClients.clear();
     res.end();
     process.exit();
   } else if (req.originalUrl === "/api/state") {
@@ -117,6 +127,80 @@ const shellyMiddleware = (
   }
 };
 
+const updateOcppState = () => {
+  state.ocpp.clients = Array.from(ocppClients.entries()).map(([stationId, client]) => ({
+    stationId,
+    serverUrl: client.getServerUrl(),
+    connected: client.isConnected(),
+  }));
+};
+
+const ocppMiddleware = (
+  req: Connect.IncomingMessage,
+  res: ServerResponse,
+  next: Connect.NextFunction
+) => {
+  if (req.method === "POST" && req.originalUrl === "/api/ocpp/connect") {
+    console.log("[simulator] POST /api/ocpp/connect");
+    // @ts-expect-error Property 'body' does not exist on type 'IncomingMessage'
+    const { stationId, serverUrl } = req.body;
+
+    if (!stationId || !serverUrl) {
+      res.statusCode = 400;
+      res.end(JSON.stringify({ error: "stationId and serverUrl required" }));
+      return;
+    }
+
+    if (ocppClients.has(stationId)) {
+      res.statusCode = 400;
+      res.end(JSON.stringify({ error: `Client ${stationId} already exists` }));
+      return;
+    }
+
+    const client = new OcppClient(stationId, serverUrl);
+    ocppClients.set(stationId, client);
+
+    client
+      .connect()
+      .then(() => client.bootNotification())
+      .then((response) => {
+        console.log("[simulator] OCPP BootNotification response:", response);
+        updateOcppState();
+        res.end(JSON.stringify({ status: "connected", stationId, response }));
+      })
+      .catch((error) => {
+        console.error("[simulator] OCPP connection error:", error);
+        ocppClients.delete(stationId);
+        updateOcppState();
+        res.statusCode = 500;
+        res.end(JSON.stringify({ error: error.message }));
+      });
+  } else if (req.method === "POST" && req.originalUrl === "/api/ocpp/disconnect") {
+    console.log("[simulator] POST /api/ocpp/disconnect");
+    // @ts-expect-error Property 'body' does not exist on type 'IncomingMessage'
+    const { stationId } = req.body;
+
+    if (!stationId) {
+      res.statusCode = 400;
+      res.end(JSON.stringify({ error: "stationId required" }));
+      return;
+    }
+
+    const client = ocppClients.get(stationId);
+    if (client) {
+      client.disconnect();
+      ocppClients.delete(stationId);
+      updateOcppState();
+      res.end(JSON.stringify({ status: "disconnected", stationId }));
+    } else {
+      res.statusCode = 404;
+      res.end(JSON.stringify({ error: `Client ${stationId} not found` }));
+    }
+  } else {
+    next();
+  }
+};
+
 export default () => ({
   name: "api",
   enforce: "pre",
@@ -129,6 +213,7 @@ export default () => ({
       server.middlewares.use(openemsMiddleware);
       server.middlewares.use(teslaloggerMiddleware);
       server.middlewares.use(shellyMiddleware);
+      server.middlewares.use(ocppMiddleware);
     };
   },
 });
diff --git a/tests/simulator/ocppClient.ts b/tests/simulator/ocppClient.ts
new file mode 100644
index 00000000..eae7b22e
--- /dev/null
+++ b/tests/simulator/ocppClient.ts
@@ -0,0 +1,221 @@
+import WebSocket from "ws";
+
+export class OcppClient {
+  private ws: WebSocket | null = null;
+  private messageId = 0;
+  private pendingCallbacks = new Map<string, (response: any) => void>();
+  private connected = false;
+  private stationId: string;
+  private serverUrl: string;
+
+  constructor(stationId: string, serverUrl: string) {
+    this.stationId = stationId;
+    this.serverUrl = serverUrl;
+  }
+
+  async connect(): Promise<void> {
+    return new Promise((resolve, reject) => {
+      const url = `${this.serverUrl}${this.stationId}`;
+      console.log(`[OCPP Client] Connecting to ${url}`);
+
+      this.ws = new WebSocket(url, ["ocpp1.6"]);
+
+      this.ws.on("open", () => {
+        console.log(`[OCPP Client] Connected as ${this.stationId}`);
+        this.connected = true;
+        resolve();
+      });
+
+      this.ws.on("message", (data: WebSocket.Data) => {
+        const message = JSON.parse(data.toString());
+        this.handleMessage(message);
+      });
+
+      this.ws.on("error", (error) => {
+        console.error(`[OCPP Client] Error:`, error);
+        reject(error);
+      });
+
+      this.ws.on("close", () => {
+        console.log(`[OCPP Client] Disconnected`);
+        this.connected = false;
+      });
+
+      // Timeout after 5 seconds
+      setTimeout(() => {
+        if (!this.connected) {
+          this.disconnect();
+          reject(new Error("Connection timeout"));
+        }
+      }, 5000);
+    });
+  }
+
+  private handleMessage(message: any[]) {
+    const messageType = message[0];
+
+    // Handle CallResult (3) - response to our calls
+    if (messageType === 3) {
+      const messageId = message[1];
+      const payload = message[2];
+      const callback = this.pendingCallbacks.get(messageId);
+      if (callback) {
+        callback(payload);
+        this.pendingCallbacks.delete(messageId);
+      }
+    }
+    // Handle Call (2) - requests from server
+    else if (messageType === 2) {
+      const messageId = message[1];
+      const action = message[2];
+      const payload = message[3];
+      console.log(`[OCPP Client] Received ${action}:`, payload);
+
+      // Auto-respond to common server requests
+      this.handleServerRequest(messageId, action, payload);
+    }
+  }
+
+  private handleServerRequest(messageId: string, action: string, payload: any) {
+    let response: any = {};
+
+    switch (action) {
+      case "GetConfiguration":
+        response = {
+          configurationKey: [
+            { key: "NumberOfConnectors", readonly: true, value: "1" },
+            {
+              key: "MeterValuesSampledData",
+              readonly: false,
+              value: "Energy.Active.Import.Register,Power.Active.Import",
+            },
+          ],
+        };
+        break;
+      case "ChangeAvailability":
+        response = { status: "Accepted" };
+        break;
+      case "RemoteStartTransaction":
+        response = { status: "Accepted" };
+        break;
+      case "RemoteStopTransaction":
+        response = { status: "Accepted" };
+        break;
+      case "TriggerMessage":
+        // Handle trigger and send the requested message
+        if (payload.requestedMessage === "StatusNotification") {
+          this.statusNotification(1, "Available");
+        }
+        response = { status: "Accepted" };
+        break;
+      default:
+        console.log(`[OCPP Client] Unknown action ${action}, sending empty response`);
+    }
+
+    // Send CallResult
+    const responseMessage = [3, messageId, response];
+    this.send(responseMessage);
+  }
+
+  private send(message: any): void {
+    if (this.ws && this.connected) {
+      const data = JSON.stringify(message);
+      console.log(`[OCPP Client] Sending:`, data);
+      this.ws.send(data);
+    }
+  }
+
+  private async call(action: string, payload: any): Promise<any> {
+    return new Promise((resolve, reject) => {
+      const messageId = (++this.messageId).toString();
+      const message = [2, messageId, action, payload];
+
+      // Setup callback for response
+      this.pendingCallbacks.set(messageId, resolve);
+
+      // Send message
+      this.send(message);
+
+      // Timeout after 10 seconds
+      setTimeout(() => {
+        if (this.pendingCallbacks.has(messageId)) {
+          this.pendingCallbacks.delete(messageId);
+          reject(new Error(`Timeout waiting for response to ${action}`));
+        }
+      }, 10000);
+    });
+  }
+
+  async bootNotification(model = "Simulator", vendor = "evcc-test"): Promise<any> {
+    return this.call("BootNotification", {
+      chargePointModel: model,
+      chargePointVendor: vendor,
+      chargePointSerialNumber: this.stationId,
+      firmwareVersion: "1.0.0",
+    });
+  }
+
+  async statusNotification(
+    connectorId: number,
+    status: string,
+    errorCode = "NoError"
+  ): Promise<any> {
+    return this.call("StatusNotification", {
+      connectorId,
+      status,
+      errorCode,
+      timestamp: new Date().toISOString(),
+    });
+  }
+
+  async meterValues(connectorId: number, power: number, energy: number): Promise<any> {
+    return this.call("MeterValues", {
+      connectorId,
+      meterValue: [
+        {
+          timestamp: new Date().toISOString(),
+          sampledValue: [
+            {
+              value: power.toString(),
+              context: "Sample.Periodic",
+              format: "Raw",
+              measurand: "Power.Active.Import",
+              phase: null,
+              location: "Outlet",
+              unit: "W",
+            },
+            {
+              value: energy.toString(),
+              context: "Sample.Periodic",
+              format: "Raw",
+              measurand: "Energy.Active.Import.Register",
+              phase: null,
+              location: "Outlet",
+              unit: "Wh",
+            },
+          ],
+        },
+      ],
+    });
+  }
+
+  disconnect(): void {
+    if (this.ws) {
+      this.ws.close();
+      this.ws = null;
+      this.connected = false;
+    }
+  }
+
+  isConnected(): boolean {
+    return this.connected;
+  }
+
+  getStationId(): string {
+    return this.stationId;
+  }
+
+  getServerUrl(): string {
+    return this.serverUrl;
+  }
+}
diff --git a/tests/simulator/src/Simulator.vue b/tests/simulator/src/Simulator.vue
index a6d0618f..22e5fe69 100644
--- a/tests/simulator/src/Simulator.vue
+++ b/tests/simulator/src/Simulator.vue
@@ -250,6 +250,80 @@
 				</div>
 			</div>
 		</div>
+
+		<h4 class="my-4">OCPP <small>work in progress, connect only</small></h4>
+
+		<!-- Connected Clients -->
+		<div
+			v-for="client in state.ocpp.clients"
+			:key="client.stationId"
+			class="card mb-3"
+			:data-testid="`ocpp-client-${client.stationId}`"
+		>
+			<div class="card-body">
+				<div class="d-flex justify-content-between align-items-center mb-2">
+					<h5 class="card-title mb-0">{{ client.stationId }}</h5>
+					<span class="badge" :class="client.connected ? 'bg-success' : 'bg-secondary'">
+						{{ client.connected ? "Connected" : "Disconnected" }}
+					</span>
+				</div>
+				<p class="card-text text-muted small mb-2">{{ client.serverUrl }}</p>
+				<button
+					type="button"
+					class="btn btn-sm btn-danger"
+					@click="disconnectOcpp(client.stationId)"
+				>
+					Disconnect
+				</button>
+			</div>
+		</div>
+
+		<!-- Add OCPP Client Card -->
+		<div class="card mb-3" data-testid="ocpp-add-client">
+			<div class="card-body">
+				<h5 class="card-title">OCPP Client</h5>
+				<div class="row">
+					<label for="ocppServerUrl" class="col-sm-6 col-form-label">Server URL</label>
+					<div class="col-sm-6">
+						<div class="input-group mb-3">
+							<input
+								id="ocppServerUrl"
+								v-model="ocppServerUrl"
+								type="text"
+								class="form-control"
+							/>
+						</div>
+					</div>
+				</div>
+				<div class="row">
+					<label for="ocppStationId" class="col-sm-6 col-form-label">Station ID</label>
+					<div class="col-sm-6">
+						<div class="input-group mb-3">
+							<input
+								id="ocppStationId"
+								v-model="ocppStationId"
+								type="text"
+								class="form-control"
+							/>
+						</div>
+					</div>
+				</div>
+				<div class="row">
+					<div class="col-sm-6"></div>
+					<div class="col-sm-6">
+						<button
+							type="button"
+							class="btn btn-primary w-100"
+							:disabled="!ocppServerUrl || !ocppStationId || connecting"
+							@click="connectOcpp"
+						>
+							{{ connecting ? "Connecting..." : "Connect" }}
+						</button>
+					</div>
+				</div>
+			</div>
+		</div>
+
 		<div class="p-4 text-center fixed-bottom bg-light text-dark bg-opacity-75">
 			<button type="submit" class="btn btn-primary">Apply changes</button>
 		</div>
@@ -278,7 +352,13 @@ export default defineComponent({
 				}[];
 				vehicles: { soc: number; range: number }[];
 				hems: { relay: boolean };
+				ocpp: {
+					clients: { stationId: string; serverUrl: string; connected: boolean }[];
+				};
 			} | null,
+			ocppServerUrl: "ws://127.0.0.1:8887/",
+			ocppStationId: "",
+			connecting: false,
 		};
 	},
 	mounted() {
@@ -292,6 +372,33 @@ export default defineComponent({
 			const response = await axios.get("/api/state");
 			this.state = response.data;
 		},
+		async connectOcpp() {
+			this.connecting = true;
+			try {
+				await axios.post("/api/ocpp/connect", {
+					stationId: this.ocppStationId,
+					serverUrl: this.ocppServerUrl,
+				});
+				// Reload state to get updated ocpp status
+				await this.load();
+				this.ocppStationId = "";
+			} catch (error) {
+				console.error("Failed to connect OCPP client:", error);
+				alert("Failed to connect OCPP client");
+			} finally {
+				this.connecting = false;
+			}
+		},
+		async disconnectOcpp(stationId: string) {
+			try {
+				await axios.post("/api/ocpp/disconnect", { stationId });
+				// Reload state to get updated ocpp status
+				await this.load();
+			} catch (error) {
+				console.error("Failed to disconnect OCPP client:", error);
+				alert("Failed to disconnect OCPP client");
+			}
+		},
 		addVehicle() {
 			// push a duplacate of the last entry
 			const vehicles = this.state?.vehicles;
@@ -327,6 +434,7 @@ input[type="number"]::-webkit-inner-spin-button {
 	margin: 0;
 }
 input[type="number"] {
+	appearance: textfield;
 	-moz-appearance: textfield;
 }
 input[type="number"] {