Repository: evcc-io/evcc
Pull request #18758 (2025-02-12 23:02:10)

Translations update from Hosted Weblate
Translations update from [Hosted Weblate](https://hosted.weblate.org) for [evcc/evcc](https://hosted.weblate.org/projects/evcc/evcc/).



Current translation status:

![Weblate translation status](https://hosted.weblate.org/widget/evcc/evcc/horizontal-auto.svg)


Patch:
diff --git a/assets/css/app.css b/assets/css/app.css
index 3d1f6e98..a139c00c 100644
--- a/assets/css/app.css
+++ b/assets/css/app.css
@@ -47,6 +47,8 @@
 	--evcc-pv: var(--evcc-dark-green);
 	--evcc-battery: var(--evcc-darker-green);
 	--evcc-export: var(--evcc-yellow);
+	--evcc-price: #ff912fff;
+	--evcc-co2: #00916eff;
 	--evcc-background: var(--bs-gray-bright);
 	--evcc-box: var(--bs-white);
 	--evcc-box-border: var(--bs-gray-brighter);
@@ -165,6 +167,14 @@ h5 {
 	color: var(--bs-primary) !important;
 }
 
+.text-price {
+	color: var(--evcc-price) !important;
+}
+
+.text-co2 {
+	color: var(--evcc-co2) !important;
+}
+
 a {
 	color: var(--bs-primary);
 }
diff --git a/assets/js/colors.js b/assets/js/colors.js
index 2e536a66..5bc721b5 100644
--- a/assets/js/colors.js
+++ b/assets/js/colors.js
@@ -15,9 +15,10 @@ const colors = reactive({
   grid: null,
   co2PerKWh: null,
   pricePerKWh: null,
-  price: "#FF912FFF",
-  co2: "#00916EFF",
+  price: null,
+  co2: null,
   background: null,
+  light: null,
   selfPalette: ["#0FDE41FF", "#FFBD2FFF", "#FD6158FF", "#03C1EFFF", "#0F662DFF", "#FF922EFF"],
   palette: [
     "#03C1EFFF",
@@ -35,6 +36,18 @@ const colors = reactive({
   ],
 });
 
+export const dimColor = (color) => {
+  return color.toLowerCase().replace(/ff$/, "20");
+};
+
+export const lighterColor = (color) => {
+  return color.toLowerCase().replace(/ff$/, "aa");
+};
+
+export const fullColor = (color) => {
+  return color.toLowerCase().replace(/20$/, "ff");
+};
+
 function updateCssColors() {
   const style = window.getComputedStyle(document.documentElement);
   colors.text = style.getPropertyValue("--evcc-default-text");
@@ -42,22 +55,19 @@ function updateCssColors() {
   colors.border = style.getPropertyValue("--bs-border-color-translucent");
   colors.self = style.getPropertyValue("--evcc-self");
   colors.grid = style.getPropertyValue("--evcc-grid");
+  colors.price = style.getPropertyValue("--evcc-price");
+  colors.co2 = style.getPropertyValue("--evcc-co2");
   colors.background = style.getPropertyValue("--evcc-background");
   colors.pricePerKWh = style.getPropertyValue("--bs-gray-medium");
   colors.co2PerKWh = style.getPropertyValue("--bs-gray-medium");
+  colors.light = style.getPropertyValue("--bs-gray-light");
 }
 
 // update colors on theme change
 const darkModeMatcher = window.matchMedia && window.matchMedia("(prefers-color-scheme: dark)");
 darkModeMatcher?.addEventListener("change", updateCssColors);
+// initialize colors
 updateCssColors();
-
-export const dimColor = (color) => {
-  return color.toLowerCase().replace(/ff$/, "20");
-};
-
-export const fullColor = (color) => {
-  return color.toLowerCase().replace(/20$/, "ff");
-};
+window.requestAnimationFrame(updateCssColors);
 
 export default colors;
diff --git a/assets/js/components/Energyflow/Energyflow.vue b/assets/js/components/Energyflow/Energyflow.vue
index 35316e4f..c221f8b4 100644
--- a/assets/js/components/Energyflow/Energyflow.vue
+++ b/assets/js/components/Energyflow/Energyflow.vue
@@ -83,8 +83,15 @@
 							icon="sun"
 							:power="pvProduction"
 							:powerTooltip="pvTooltip"
+							:details="solarForecast"
+							:detailsFmt="forecastFmt"
+							:detailsTooltip="forecastTooltip(solarForecast)"
+							:detailsInactive="solarForecast === 0"
+							:detailsIcon="solarForecast !== undefined ? 'forecast' : undefined"
+							:detailsClickable="solarForecast !== undefined"
 							:powerUnit="powerUnit"
 							data-testid="energyflow-entry-production"
+							@details-clicked="openForecastModal"
 						/>
 						<EnergyflowEntry
 							v-if="batteryConfigured"
@@ -229,7 +236,7 @@ import AnimatedNumber from "../AnimatedNumber.vue";
 import settings from "../../settings";
 import { CO2_TYPE } from "../../units";
 import collector from "../../mixins/collector";
-
+import { energyByDay } from "../../utils/forecast";
 export default {
 	name: "Energyflow",
 	components: {
@@ -266,6 +273,7 @@ export default {
 		prioritySoc: { type: Number },
 		bufferSoc: { type: Number },
 		bufferStartSoc: { type: Number },
+		forecast: { type: Object, default: () => ({}) },
 	},
 	data: () => {
 		return { detailsOpen: false, detailsCompleteHeight: null, ready: false };
@@ -381,6 +389,11 @@ export default {
 			}
 			return this.fmtPricePerKWh(this.batteryGridChargeLimit, this.currency, true);
 		},
+		solarForecast() {
+			const slots = this.forecast.solar || [];
+			if (slots.length === 0) return undefined;
+			return energyByDay(slots, 0);
+		},
 	},
 	watch: {
 		pvConfigured() {
@@ -418,6 +431,12 @@ export default {
 			}
 			return result;
 		},
+		forecastTooltip(value) {
+			if (value !== null) {
+				return [this.$t("main.energyflow.forecastTooltip")];
+			}
+			return [];
+		},
 		detailsValue(price, co2) {
 			if (this.co2Available) {
 				return co2;
@@ -430,6 +449,12 @@ export default {
 			}
 			return this.fmtPricePerKWh(value, this.currency, true);
 		},
+		forecastFmt(value) {
+			if (value === null) {
+				return "";
+			}
+			return `${this.fmtWh(value, POWER_UNIT.KW)}`;
+		},
 		kw: function (watt) {
 			return this.fmtW(watt, this.powerUnit);
 		},
@@ -447,6 +472,10 @@ export default {
 			);
 			modal.show();
 		},
+		openForecastModal() {
+			const modal = Modal.getOrCreateInstance(document.getElementById("forecastModal"));
+			modal.show();
+		},
 		dischargePower(power) {
 			return Math.abs(Math.max(0, power));
 		},
diff --git a/assets/js/components/Energyflow/EnergyflowEntry.vue b/assets/js/components/Energyflow/EnergyflowEntry.vue
index 5ec1410a..040c1548 100644
--- a/assets/js/components/Energyflow/EnergyflowEntry.vue
+++ b/assets/js/components/Energyflow/EnergyflowEntry.vue
@@ -13,12 +13,19 @@
 				<div
 					ref="details"
 					class="fw-normal"
-					:class="{ 'text-decoration-underline': detailsClickable }"
+					:class="{
+						'text-decoration-underline': detailsClickable,
+						'evcc-gray': detailsInactive,
+					}"
 					data-testid="energyflow-entry-details"
 					data-bs-toggle="tooltip"
 					:tabindex="detailsClickable ? 0 : undefined"
 					@click="detailsClicked"
 				>
+					<ForecastIcon
+						v-if="detailsIcon === 'forecast'"
+						class="ms-2 me-1 d-inline-block"
+					/>
 					<AnimatedNumber v-if="!isNaN(details)" :to="details" :format="detailsFmt" />
 				</div>
 				<div ref="power" class="power" data-bs-toggle="tooltip" @click="powerClicked">
@@ -41,10 +48,11 @@ import BatteryIcon from "./BatteryIcon.vue";
 import formatter from "../../mixins/formatter";
 import AnimatedNumber from "../AnimatedNumber.vue";
 import VehicleIcon from "../VehicleIcon";
+import ForecastIcon from "../MaterialIcon/Forecast.vue";
 
 export default {
 	name: "EnergyflowEntry",
-	components: { BatteryIcon, AnimatedNumber, VehicleIcon },
+	components: { BatteryIcon, AnimatedNumber, VehicleIcon, ForecastIcon },
 	mixins: [formatter],
 	props: {
 		name: { type: String },
@@ -54,9 +62,11 @@ export default {
 		powerTooltip: { type: Array },
 		powerUnit: { type: String },
 		details: { type: Number },
+		detailsIcon: { type: String },
 		detailsFmt: { type: Function },
 		detailsTooltip: { type: Array },
 		detailsClickable: { type: Boolean },
+		detailsInactive: { type: Boolean },
 	},
 	emits: ["details-clicked"],
 	data() {
@@ -107,9 +117,6 @@ export default {
 			);
 		},
 		updateDetailsTooltip() {
-			if (this.detailsClickable) {
-				return;
-			}
 			this.detailsTooltipInstance = this.updateTooltip(
 				this.detailsTooltipInstance,
 				this.detailsTooltip,
@@ -143,6 +150,8 @@ export default {
 			if (this.detailsClickable) {
 				this.$emit("details-clicked");
 			}
+			// hide tooltip, chrome needs a timeout
+			setTimeout(() => this.detailsTooltipInstance?.hide(), 10);
 		},
 	},
 };
diff --git a/assets/js/components/ForecastActiveSlot.vue b/assets/js/components/ForecastActiveSlot.vue
new file mode 100644
index 00000000..f0c368ac
--- /dev/null
+++ b/assets/js/components/ForecastActiveSlot.vue
@@ -0,0 +1,35 @@
+<template>
+	<div v-if="activeSlot" class="text-end">
+		<span class="text-nowrap">{{ day }} {{ start }}</span
+		>{{ " " }}<span class="text-nowrap">– {{ end }}</span>
+	</div>
+</template>
+
+<script lang="ts">
+import { defineComponent } from "vue";
+import type { PropType } from "vue";
+import { type PriceSlot } from "../utils/forecast";
+import formatter from "../mixins/formatter";
+
+export default defineComponent({
+	name: "ForecastActiveSlot",
+	mixins: [formatter],
+	props: {
+		activeSlot: { type: Object as PropType<PriceSlot | null> },
+	},
+	computed: {
+		day() {
+			const startDate = new Date(this.activeSlot!.start);
+			return this.weekdayShort(startDate);
+		},
+		start() {
+			const startDate = new Date(this.activeSlot!.start);
+			return this.hourShort(startDate);
+		},
+		end() {
+			const endDate = new Date(this.activeSlot!.end);
+			return this.hourShort(endDate);
+		},
+	},
+});
+</script>
diff --git a/assets/js/components/ForecastChart.vue b/assets/js/components/ForecastChart.vue
new file mode 100644
index 00000000..49bb507f
--- /dev/null
+++ b/assets/js/components/ForecastChart.vue
@@ -0,0 +1,417 @@
+<template>
+	<div>
+		<div
+			class="overflow-x-auto overflow-x-md-hidden chart-container border-1"
+			@mouseleave="onMouseLeave"
+		>
+			<div style="position: relative; height: 220px" class="chart user-select-none">
+				<Bar ref="chart" :data="chartData" :options="options" />
+			</div>
+		</div>
+	</div>
+</template>
+
+<script lang="ts">
+import { defineComponent, type PropType } from "vue";
+import { Bar } from "vue-chartjs";
+import {
+	BarController,
+	BarElement,
+	LineController,
+	LineElement,
+	LinearScale,
+	TimeSeriesScale,
+	Legend,
+	Tooltip,
+	PointElement,
+	Filler,
+} from "chart.js";
+import ChartDataLabels from "chartjs-plugin-datalabels";
+import "chartjs-adapter-dayjs-4/dist/chartjs-adapter-dayjs-4.esm";
+import { registerChartComponents, commonOptions } from "./Sessions/chartConfig";
+import formatter, { POWER_UNIT } from "../mixins/formatter";
+import colors, { lighterColor } from "../colors";
+import {
+	energyByDay,
+	highestSlotIndexByDay,
+	type PriceSlot,
+	ForecastType,
+} from "../utils/forecast";
+
+registerChartComponents([
+	BarController,
+	BarElement,
+	LineController,
+	LineElement,
+	Filler,
+	LinearScale,
+	TimeSeriesScale,
+	Legend,
+	Tooltip,
+	PointElement,
+	ChartDataLabels,
+]);
+
+export default defineComponent({
+	name: "ForecastChart",
+	components: { Bar },
+	mixins: [formatter],
+	props: {
+		grid: { type: Array as PropType<PriceSlot[]> },
+		solar: { type: Array as PropType<PriceSlot[]> },
+		co2: { type: Array as PropType<PriceSlot[]> },
+		currency: { type: String as PropType<string> },
+		selected: { type: String as PropType<ForecastType> },
+	},
+	emits: ["selected"],
+	data(): {
+		selectedIndex: number | null;
+		ignoreEvents: boolean;
+		ignoreEventsTimeout: ReturnType<typeof setTimeout> | null;
+	} {
+		return {
+			selectedIndex: null,
+			ignoreEvents: false,
+			ignoreEventsTimeout: null,
+		};
+	},
+	computed: {
+		startDate() {
+			const now = new Date();
+			const slots = this.grid || this.co2 || this.solar || [];
+			const currentSlot = slots.find(({ start, end }) => {
+				return new Date(start) <= now && new Date(end) > now;
+			});
+			if (currentSlot) {
+				return new Date(currentSlot.start);
+			}
+			return now;
+		},
+		solarSlots() {
+			return this.filterSlots(this.solar);
+		},
+		gridSlots() {
+			return this.filterSlots(this.grid);
+		},
+		co2Slots() {
+			return this.filterSlots(this.co2);
+		},
+		currentSlots() {
+			switch (this.selected) {
+				case ForecastType.Price:
+					return this.gridSlots;
+				case ForecastType.Solar:
+					return this.solarSlots;
+				case ForecastType.Co2:
+					return this.co2Slots;
+				default:
+					return [];
+			}
+		},
+		selectedSlot() {
+			return this.selectedIndex !== null ? this.currentSlots[this.selectedIndex] : null;
+		},
+		maxPriceIndex() {
+			return this.maxIndex(this.gridSlots);
+		},
+		minPriceIndex() {
+			return this.minIndex(this.gridSlots);
+		},
+		maxCo2Index() {
+			return this.maxIndex(this.co2Slots);
+		},
+		minCo2Index() {
+			return this.minIndex(this.co2Slots);
+		},
+		maxSolarIndex() {
+			return this.maxIndex(this.solarSlots);
+		},
+		solarHighlights() {
+			return [0, 1, 2].map((day) => {
+				const energy = energyByDay(this.solarSlots, day);
+				const index = highestSlotIndexByDay(this.solarSlots, day);
+				return { index, energy };
+			});
+		},
+		chartData() {
+			const datasets: unknown[] = [];
+			if (this.solarSlots.length > 0) {
+				const active = this.selected === ForecastType.Solar;
+				const color = active ? colors.self : colors.border;
+				datasets.push({
+					label: ForecastType.Solar,
+					type: "line",
+					data: this.solarSlots.map((slot, index) => ({
+						y: slot.price,
+						x: new Date(slot.start),
+						highlight:
+							active &&
+							(this.selectedIndex !== null
+								? this.selectedIndex === index
+								: this.solarHighlights.find(({ index: i }) => i === index)?.energy),
+					})),
+					yAxisID: "yForecast",
+					backgroundColor: lighterColor(color),
+					borderColor: color,
+					fill: "origin",
+					tension: 0.5,
+					pointRadius: 0,
+					pointHoverRadius: active ? 4 : 0,
+					spanGaps: true,
+					order: active ? 0 : 1,
+				});
+			}
+			if (this.gridSlots.length > 0) {
+				const active = this.selected === ForecastType.Price;
+				const color = active ? colors.price : colors.border;
+				datasets.push({
+					label: ForecastType.Price,
+					data: this.gridSlots.map((slot, index) => ({
+						y: slot.price,
+						x: new Date(slot.start),
+						highlight:
+							active &&
+							(this.selectedIndex !== null
+								? this.selectedIndex === index
+								: index === this.maxPriceIndex || index === this.minPriceIndex),
+					})),
+					yAxisID: "yPrice",
+					borderRadius: 8,
+					backgroundColor: color,
+					borderColor: color,
+					order: active ? 0 : 1,
+				});
+			}
+			if (this.co2Slots.length > 0) {
+				const active = this.selected === ForecastType.Co2;
+				const color = active ? colors.co2 : colors.border;
+				datasets.push({
+					label: ForecastType.Co2,
+					type: "line",
+					data: this.co2Slots.map((slot, index) => {
+						const dataActive =
+							active &&
+							(this.selectedIndex !== null
+								? this.selectedIndex === index
+								: index === this.maxCo2Index || index === this.minCo2Index);
+						return {
+							y: slot.price,
+							x: new Date(slot.start),
+							highlight: dataActive,
+							active: dataActive,
+						};
+					}),
+					yAxisID: "yCo2",
+					backgroundColor: color,
+					borderColor: color,
+					tension: 0.25,
+					pointRadius: 0,
+					pointHoverRadius: active ? 4 : 0,
+					spanGaps: true,
+					order: active ? 0 : 1,
+				});
+			}
+
+			return {
+				datasets,
+			};
+		},
+		options() {
+			// eslint-disable-next-line @typescript-eslint/no-this-alias
+			const vThis = this;
+			return {
+				...commonOptions,
+				locale: this.$i18n?.locale,
+				layout: { padding: { top: 32 } },
+				color: colors.text,
+				borderSkipped: false,
+				animation: {
+					duration: 500, // --evcc-transition-medium
+					colors: true,
+					numbers: false,
+				},
+				interaction: {
+					mode: "index",
+					axis: "x",
+					intersect: false,
+				},
+				categoryPercentage: 0.7,
+				events: ["mousemove", "click", "touchstart", "touchend"],
+				onHover: function (event, active) {
+					if (["touchend", "click"].includes(event.type)) {
+						vThis.selectIndex(null, true);
+						return;
+					}
+					const element = active.find(({ datasetIndex }) => {
+						const { label } = event.chart.getDatasetMeta(datasetIndex);
+						return label === vThis.selected;
+					});
+					vThis.selectIndex(element ? element.index : null);
+				},
+				plugins: {
+					...commonOptions.plugins,
+					datalabels: {
+						backgroundColor: function (context) {
+							return context.dataset.borderColor;
+						},
+						align: function (context) {
+							// rotate label position to avoid horizontal clipping
+							const step = 20;
+							const adjust = {
+								0: step * 2,
+								1: step * 1,
+								46: step * -1,
+								47: step * -2,
+							};
+							return -90 + (adjust[context.dataIndex] || 0);
+						},
+						anchor: "end",
+						offset: 8,
+						padding: function (context) {
+							const data = context.dataset.data[context.dataIndex];
+							const x = typeof data.highlight === "number" ? 32 : 8;
+							return {
+								x,
+								y: 4,
+							};
+						},
+						borderRadius: 4,
+						color: colors.background,
+						font: { weight: "bold" },
+						formatter: function (data, ctx) {
+							if (data.highlight) {
+								switch (ctx.dataset.label) {
+									case ForecastType.Price:
+										return vThis.fmtPricePerKWh(
+											data.y,
+											vThis.currency,
+											true,
+											true
+										);
+									case ForecastType.Co2:
+										return vThis.fmtGrams(data.y);
+									case ForecastType.Solar:
+										if (data.highlight === true) {
+											return vThis.fmtW(data.y, POWER_UNIT.AUTO);
+										} else {
+											return vThis.fmtWh(data.highlight, POWER_UNIT.AUTO);
+										}
+									default:
+										return null;
+								}
+							}
+							return null;
+						},
+					},
+					tooltip: null,
+				},
+				scales: {
+					x: {
+						type: "timeseries",
+						display: true,
+						time: { unit: "day" },
+						border: { display: false },
+						grid: {
+							display: true,
+							color: colors.border,
+							offset: false,
+							lineWidth: function (context) {
+								if (context.type !== "tick") {
+									return 0;
+								}
+								const label = context.tick?.label;
+								return Array.isArray(label) ? 1 : 0;
+							},
+						},
+						ticks: {
+							color: colors.muted,
+							autoSkip: false,
+							maxRotation: 0,
+							minRotation: 0,
+							source: "data",
+							align: "center",
+							callback: function (value) {
+								const date = new Date(value);
+								const hour = date.getHours();
+								const hourFmt = vThis.hourShort(date);
+								if (hour === 0) {
+									return [hourFmt, vThis.weekdayShort(date)];
+								}
+								if (hour % 6 === 0) {
+									return hourFmt;
+								}
+								return "";
+							},
+						},
+					},
+					yForecast: { display: false, min: 0, max: this.yMax(this.solarSlots) },
+					yCo2: { display: false, min: 0, max: this.yMax(this.co2Slots) },
+					yPrice: { display: false, min: 0, max: this.yMax(this.gridSlots) },
+				},
+			};
+		},
+	},
+	watch: {
+		selectedSlot(slot) {
+			this.$emit("selected", slot);
+		},
+	},
+	methods: {
+		filterSlots(slots: PriceSlot[] = []) {
+			return slots.filter((slot) => new Date(slot.end) > this.startDate).slice(0, 48);
+		},
+		onMouseLeave() {
+			this.selectIndex(null, true);
+		},
+		selectIndex(index: number | null, timeout = false) {
+			if (this.ignoreEvents) return;
+			this.selectedIndex = index;
+
+			// reset hover state (points, highlights)
+			if (this.selectedIndex === null) {
+				this.$nextTick(() => {
+					// @ts-expect-error unknown chart type
+					this.$refs.chart?.chart?.setActiveElements([]);
+				});
+			}
+
+			// ignore events after selection reset because chart.js triggers delayed mousemove events
+			if (timeout) {
+				this.ignoreEvents = true;
+				this.ignoreEventsTimeout = setTimeout(() => {
+					this.ignoreEvents = false;
+				}, 100);
+			}
+		},
+		yMax(slots: PriceSlot[] = []): number | undefined {
+			const value = this.maxValue(slots);
+			return value ? value * 1.15 : undefined;
+		},
+		maxIndex(slots: PriceSlot[] = []) {
+			return slots.reduce((max, slot, index) => {
+				return slot.price > slots[max].price ? index : max;
+			}, 0);
+		},
+		minIndex(slots: PriceSlot[] = []) {
+			return slots.reduce((min, slot, index) => {
+				return slot.price < slots[min].price ? index : min;
+			}, 0);
+		},
+		maxValue(slots: PriceSlot[] = []) {
+			return slots[this.maxIndex(slots)]?.price || null;
+		},
+	},
+});
+</script>
+
+<style scoped>
+.chart {
+	width: 780px;
+}
+
+@media (min-width: 992px) {
+	.chart {
+		width: 100%;
+	}
+}
+</style>
diff --git a/assets/js/components/ForecastDetails.vue b/assets/js/components/ForecastDetails.vue
new file mode 100644
index 00000000..bfe77e4d
--- /dev/null
+++ b/assets/js/components/ForecastDetails.vue
@@ -0,0 +1,190 @@
+<template>
+	<div v-if="isSolar && solarEnergy" class="row">
+		<div class="col-6 col-sm-4 mb-3 d-flex flex-column">
+			<div class="label">{{ label("today") }}</div>
+			<div class="value d-flex flex-column flex-lg-row gap-lg-2 align-items-lg-baseline">
+				<div class="text-primary text-nowrap">{{ solarEnergy.today.energy }}</div>
+				<div class="extraValue text-nowrap">{{ label("remaining") }}</div>
+			</div>
+		</div>
+		<div class="col-6 col-sm-4 mb-3 d-flex flex-column align-items-end align-items-sm-center">
+			<div class="label">{{ label("tomorrow") }}</div>
+			<div
+				class="value d-flex flex-column flex-lg-row gap-lg-2 align-items-end align-items-sm-center align-items-lg-baseline"
+			>
+				<div class="text-primary text-nowrap">{{ solarEnergy.tomorrow.energy }}</div>
+				<div v-if="solarEnergy.tomorrow.incomplete" class="extraValue text-nowrap">
+					{{ label("partly") }}
+				</div>
+			</div>
+		</div>
+		<div class="col-6 col-sm-4 mb-3 d-flex flex-column align-items-start align-items-sm-end">
+			<div class="label">{{ label("dayAfterTomorrow") }}</div>
+			<div
+				class="value d-flex flex-column flex-lg-row gap-lg-2 align-items-start align-items-sm-end align-items-lg-baseline"
+			>
+				<div class="text-primary text-nowrap">
+					{{ solarEnergy.dayAfterTomorrow.energy }}
+				</div>
+				<div v-if="solarEnergy.dayAfterTomorrow.incomplete" class="extraValue text-nowrap">
+					{{ label("partly") }}
+				</div>
+			</div>
+		</div>
+	</div>
+	<div v-else class="row">
+		<div class="col-12 col-sm-6 col-lg-4 mb-3 d-flex flex-column">
+			<div class="label">{{ label("range") }}</div>
+			<div class="value text-price text-nowrap" :class="highlightColor">
+				{{ priceRange }}
+			</div>
+		</div>
+		<div
+			class="col-12 col-sm-6 col-lg-4 mb-3 d-flex flex-column align-items-sm-end align-items-lg-center"
+		>
+			<div class="label">{{ label("average") }}</div>
+			<div class="value text-price text-nowrap" :class="highlightColor">
+				{{ averagePrice }}
+			</div>
+		</div>
+		<div
+			class="col-12 col-sm-6 col-lg-4 mb-3 d-flex flex-column align-items-sm-start align-items-lg-end"
+		>
+			<div class="label">{{ label("lowestHour") }}</div>
+			<div class="value text-price text-nowrap" :class="highlightColor">
+				{{ lowestPriceHour }}
+			</div>
+		</div>
+	</div>
+</template>
+
+<script lang="ts">
+import { defineComponent } from "vue";
+import {
+	energyByDay,
+	dayStringByOffset,
+	filterSlotsByDate,
+	ForecastType,
+	type PriceSlot,
+} from "../utils/forecast";
+import formatter, { POWER_UNIT } from "../mixins/formatter";
+
+const LOCALES_WITHOUT_DAY_AFTER_TOMORROW = ["en", "tr"];
+
+export interface Energy {
+	energy: string;
+	incomplete: boolean;
+}
+
+export interface EnergyByDay {
+	today: Energy;
+	tomorrow: Energy;
+	dayAfterTomorrow: Energy;
+}
+
+export default defineComponent({
+	name: "ForecastDetails",
+	mixins: [formatter],
+	props: {
+		type: { type: String as () => ForecastType, required: true },
+		slots: { type: Array as () => PriceSlot[], default: () => [] },
+		currency: { type: String },
+	},
+	computed: {
+		isSolar() {
+			return this.type === ForecastType.Solar;
+		},
+		upcomingSlots() {
+			const now = new Date();
+			return this.slots.filter((slot) => new Date(slot.end) > now).slice(0, 48);
+		},
+		solarEnergy(): EnergyByDay | undefined {
+			if (!this.isSolar) return;
+
+			const days = ["today", "tomorrow", "dayAfterTomorrow"];
+			return days.reduce((acc, day, index) => {
+				const energy = energyByDay(this.slots, index);
+				const date = new Date();
+				date.setDate(date.getDate() + index);
+				const dayString = dayStringByOffset(index);
+				const count = filterSlotsByDate(this.slots, dayString).length;
+				const empty = count === 0;
+				const complete = count === 24;
+				const energyFmt = empty ? "-" : this.fmtWh(energy, POWER_UNIT.AUTO);
+				acc[day] = {
+					energy: energyFmt,
+					incomplete: energy && !complete && !empty,
+				};
+				return acc;
+			}, {} as EnergyByDay);
+		},
+		averagePrice() {
+			if (this.isSolar) return "";
+			const slots = this.upcomingSlots;
+			const price = slots.reduce((acc, slot) => acc + slot.price, 0) / slots.length;
+			return this.fmtValue(price, true);
+		},
+		priceRange() {
+			if (this.isSolar) return "";
+			const slots = this.upcomingSlots;
+			const min = Math.min(...slots.map((slot) => slot.price));
+			const max = Math.max(...slots.map((slot) => slot.price));
+			return `${this.fmtValue(min, false)} – ${this.fmtValue(max, true)}`;
+		},
+		lowestPriceHour() {
+			if (this.isSolar) return "";
+			const slots = this.upcomingSlots;
+			const min = Math.min(...slots.map((slot) => slot.price));
+			const slot = slots.find((slot) => slot.price === min);
+			if (!slot) return "";
+			const start = new Date(slot.start);
+			const end = new Date(slot.end);
+			return `${this.weekdayShort(start)} ${this.hourShort(start)} – ${this.hourShort(end)}`;
+		},
+		highlightColor() {
+			const colors = {
+				[ForecastType.Price]: "text-price",
+				[ForecastType.Co2]: "text-co2",
+			};
+			return colors[this.type];
+		},
+	},
+	methods: {
+		label(key: string) {
+			// special case "day after tomorrow"
+			if (
+				key === "dayAfterTomorrow" &&
+				LOCALES_WITHOUT_DAY_AFTER_TOMORROW.includes(this.$i18n.locale)
+			) {
+				const date = new Date();
+				date.setDate(date.getDate() + 2);
+				return this.fmtDayMonth(date);
+			}
+
+			return this.$t(`forecast.${this.type}.${key}`);
+		},
+		fmtValue(value: number, withUnit = true) {
+			if (this.type === ForecastType.Price) {
+				return this.fmtPricePerKWh(value, this.currency, false, withUnit);
+			}
+			return withUnit ? this.fmtCo2Medium(value) : this.fmtNumber(value, 0);
+		},
+	},
+});
+</script>
+
+<style scoped>
+.value {
+	font-size: 18px;
+	font-weight: bold;
+}
+.extraValue {
+	color: var(--evcc-gray);
+	font-size: 14px;
+	font-weight: normal;
+}
+.label {
+	color: var(--evcc-gray);
+	text-transform: uppercase;
+}
+</style>
diff --git a/assets/js/components/ForecastModal.vue b/assets/js/components/ForecastModal.vue
new file mode 100644
index 00000000..8bca85ee
--- /dev/null
+++ b/assets/js/components/ForecastModal.vue
@@ -0,0 +1,135 @@
+<template>
+	<GenericModal
+		id="forecastModal"
+		:title="$t('forecast.modalTitle')"
+		size="xl"
+		data-testid="forecast-modal"
+		@open="modalVisible"
+		@closed="modalInvisible"
+	>
+		<div v-if="isModalVisible" class="d-flex justify-content-between align-items-center gap-2">
+			<ForecastTypeSelect v-model="selectedType" :forecast="forecast" />
+			<ForecastActiveSlot :activeSlot="selectedSlot" />
+		</div>
+
+		<ForecastChart
+			class="my-3"
+			:grid="forecast.grid"
+			:solar="forecast.solar"
+			:co2="forecast.co2"
+			:currency="currency"
+			:selected="selectedType"
+			@selected="updateSlot"
+		/>
+
+		<ForecastDetails :type="selectedType" :slots="selectedSlots" :currency="currency" />
+	</GenericModal>
+</template>
+
+<script lang="ts">
+import "@h2d2/shopicons/es/regular/eco1";
+import "@h2d2/shopicons/es/regular/sun";
+import { defineComponent } from "vue";
+import type { PropType } from "vue";
+import GenericModal from "./GenericModal.vue";
+import ForecastChart from "./ForecastChart.vue";
+import ForecastTypeSelect from "./ForecastTypeSelect.vue";
+import ForecastDetails from "./ForecastDetails.vue";
+import ForecastActiveSlot from "./ForecastActiveSlot.vue";
+import { type PriceSlot, ForecastType } from "../utils/forecast";
+import formatter from "../mixins/formatter";
+
+interface Forecast {
+	grid?: PriceSlot[];
+	solar?: PriceSlot[];
+	co2?: PriceSlot[];
+}
+
+export default defineComponent({
+	name: "ForecastModal",
+	components: {
+		GenericModal,
+		ForecastChart,
+		ForecastTypeSelect,
+		ForecastDetails,
+		ForecastActiveSlot,
+	},
+	mixins: [formatter],
+	props: {
+		forecast: { type: Object as PropType<Forecast>, default: () => ({}) },
+		currency: { type: String },
+	},
+	data: function (): {
+		isModalVisible: boolean;
+		selectedType: ForecastType;
+		selectedSlot: PriceSlot | null;
+	} {
+		return {
+			isModalVisible: false,
+			selectedType: ForecastType.Solar,
+			selectedSlot: null,
+		};
+	},
+	computed: {
+		selectedSlots() {
+			const slots = {
+				[ForecastType.Solar]: this.forecast.solar,
+				[ForecastType.Price]: this.forecast.grid,
+				[ForecastType.Co2]: this.forecast.co2,
+			};
+			return slots[this.selectedType] || [];
+		},
+	},
+	watch: {
+		isModalVisible: function (newVal) {
+			if (newVal) {
+				this.updateSelectedType();
+			}
+		},
+	},
+	mounted() {
+		this.updateSelectedType();
+	},
+	methods: {
+		modalVisible() {
+			this.isModalVisible = true;
+		},
+		modalInvisible() {
+			this.isModalVisible = false;
+		},
+		updateSlot(slot: PriceSlot | null) {
+			this.selectedSlot = slot;
+		},
+		updateSelectedType() {
+			const availableTypes = {
+				[ForecastType.Solar]: !!this.forecast.solar,
+				[ForecastType.Price]: !!this.forecast.grid,
+				[ForecastType.Co2]: !!this.forecast.co2,
+			};
+
+			// selection has data, do nothing
+			if (availableTypes[this.selectedType]) return;
+			// fallback to first available type
+			this.selectedType =
+				Object.values(ForecastType).find((type) => availableTypes[type]) ||
+				Object.values(ForecastType)[0];
+		},
+	},
+});
+</script>
+
+<style scoped>
+.value {
+	font-size: 18px;
+	font-weight: bold;
+}
+.extraValue {
+	color: var(--evcc-gray);
+	font-size: 14px;
+	font-weight: normal;
+}
+.label {
+	color: var(--evcc-gray);
+	text-transform: uppercase;
+}
+</style>
diff --git a/assets/js/components/ForecastTypeSelect.vue b/assets/js/components/ForecastTypeSelect.vue
new file mode 100644
index 00000000..19f50622
--- /dev/null
+++ b/assets/js/components/ForecastTypeSelect.vue
@@ -0,0 +1,57 @@
+<template>
+	<IconSelectGroup>
+		<IconSelectItem
+			v-for="type in types"
+			:key="type"
+			:active="selectedType === type"
+			:label="$t(`forecast.type.${type}`)"
+			:disabled="!availableTypes[type]"
+			hideLabelOnMobile
+			@click="$emit('update:modelValue', type)"
+		>
+			<component :is="typeIcons[type]"></component>
+		</IconSelectItem>
+	</IconSelectGroup>
+</template>
+
+<script lang="ts">
+import "@h2d2/shopicons/es/regular/eco1";
+import "@h2d2/shopicons/es/regular/sun";
+import { defineComponent } from "vue";
+import IconSelectItem from "./IconSelectItem.vue";
+import IconSelectGroup from "./IconSelectGroup.vue";
+import DynamicPriceIcon from "./MaterialIcon/DynamicPrice.vue";
+import { ForecastType } from "../utils/forecast";
+
+export default defineComponent({
+	name: "ForecastTypeSelect",
+	components: { IconSelectItem, IconSelectGroup },
+	props: {
+		modelValue: { type: String as () => ForecastType, required: true },
+		forecast: { type: Object, required: true },
+	},
+	emits: ["update:modelValue"],
+	computed: {
+		selectedType() {
+			return this.modelValue;
+		},
+		types() {
+			return Object.values(ForecastType);
+		},
+		availableTypes() {
+			return {
+				[ForecastType.Solar]: !!this.forecast.solar,
+				[ForecastType.Price]: !!this.forecast.grid,
+				[ForecastType.Co2]: !!this.forecast.co2,
+			};
+		},
+		typeIcons() {
+			return {
+				[ForecastType.Solar]: "shopicon-regular-sun",
+				[ForecastType.Price]: DynamicPriceIcon,
+				[ForecastType.Co2]: "shopicon-regular-eco1",
+			};
+		},
+	},
+});
+</script>
diff --git a/assets/js/components/IconSelectItem.vue b/assets/js/components/IconSelectItem.vue
index 0a95e12a..6e9526e7 100644
--- a/assets/js/components/IconSelectItem.vue
+++ b/assets/js/components/IconSelectItem.vue
@@ -3,12 +3,16 @@
 		<button
 			type="button"
 			class="btn gap-2 btn-sm"
-			:class="{ active, withLabel: !!label }"
+			:class="{ active, withLabel: !!label, hideLabelOnMobile }"
 			:disabled="disabled"
 			@click="$emit('click', value)"
 		>
 			<slot></slot>
-			<span v-if="label" class="text-nowrap text-truncate">
+			<span
+				v-if="label"
+				class="text-nowrap text-truncate"
+				:class="{ 'd-none d-md-inline': hideLabelOnMobile }"
+			>
 				{{ label }}
 			</span>
 		</button>
@@ -23,6 +27,7 @@ export default {
 		active: Boolean,
 		label: String,
 		disabled: Boolean,
+		hideLabelOnMobile: Boolean,
 	},
 	emits: ["click"],
 };
@@ -51,4 +56,10 @@ export default {
 	width: auto;
 	padding: 0 1rem;
 }
+@media (max-width: 768px) {
+	.btn.hideLabelOnMobile {
+		width: 32px;
+		padding: 0;
+	}
+}
 </style>
diff --git a/assets/js/components/MaterialIcon/Forecast.vue b/assets/js/components/MaterialIcon/Forecast.vue
new file mode 100644
index 00000000..8870e8c9
--- /dev/null
+++ b/assets/js/components/MaterialIcon/Forecast.vue
@@ -0,0 +1,17 @@
+<template>
+	<svg :style="svgStyle" viewBox="0 0 48 48">
+		<path
+			fill="currentColor"
+			d="M5.78,31c1.791,-3.381 3.675,-6.042 5.643,-9.059c1.422,-2.18 2.889,-4.118 4.384,-5.67l2.881,2.774c-1.338,1.39 -2.642,3.13 -3.914,5.081c-1.523,2.335 -3,4.445 -4.418,6.874l-4.576,-0Zm32.023,0c-0.661,-1.3 -1.346,-2.567 -2.051,-3.781l3.458,-2.011c1.067,1.836 2.089,3.787 3.055,5.792l-4.462,0Zm-16.172,-14.32l-1.915,-3.512c1.409,-0.768 2.842,-1.169 4.286,-1.168c1.35,0.001 2.702,0.328 4.04,0.954l-1.696,3.623c-0.78,-0.365 -1.562,-0.576 -2.348,-0.577c-0.8,-0.001 -1.586,0.254 -2.367,0.68Zm7.78,2.072l2.729,-2.924c1.591,1.484 3.149,3.369 4.639,5.53l-3.292,2.271c-1.313,-1.902 -2.675,-3.57 -4.076,-4.877Z"
+		/>
+	</svg>
+</template>
+
+<script>
+import icon from "../../mixins/icon";
+
+export default {
+	name: "Forecast",
+	mixins: [icon],
+};
+</script>
diff --git a/assets/js/components/Sessions/chartConfig.js b/assets/js/components/Sessions/chartConfig.js
index a69cbc8b..6bde1c0d 100644
--- a/assets/js/components/Sessions/chartConfig.js
+++ b/assets/js/components/Sessions/chartConfig.js
@@ -40,6 +40,7 @@ export const commonOptions = {
   maintainAspectRatio: false,
   plugins: {
     legend: { display: false },
+    datalabels: { display: false },
     tooltip: {
       backgroundColor: "#000000cc",
       boxPadding: 5,
diff --git a/assets/js/components/Site.vue b/assets/js/components/Site.vue
index 160a2560..164365c8 100644
--- a/assets/js/components/Site.vue
+++ b/assets/js/components/Site.vue
@@ -131,6 +131,7 @@ export default {
 		sponsor: { type: Object, default: () => ({}) },
 		smartCostType: String,
 		fatal: Object,
+		forecast: Object,
 	},
 	computed: {
 		batteryConfigured: function () {
diff --git a/assets/js/components/TopNavigation.vue b/assets/js/components/TopNavigation.vue
index d17d33ec..1678d205 100644
--- a/assets/js/components/TopNavigation.vue
+++ b/assets/js/components/TopNavigation.vue
@@ -48,6 +48,16 @@
 					{{ $t("batterySettings.modalTitle") }}
 				</button>
 			</li>
+			<li v-if="forecastAvailable">
+				<button
+					type="button"
+					class="dropdown-item"
+					data-testid="topnavigation-forecast"
+					@click="openForecastModal"
+				>
+					{{ $t("forecast.modalTitle") }}
+				</button>
+			</li>
 			<li>
 				<router-link class="dropdown-item" to="/config" active-class="active">
 					<span
@@ -142,6 +152,7 @@ export default {
 				return {};
 			},
 		},
+		forecast: Object,
 		battery: Array,
 		fatal: Object,
 	},
@@ -181,6 +192,10 @@ export default {
 		batteryModalAvailable() {
 			return this.batteryConfigured;
 		},
+		forecastAvailable() {
+			const { grid, solar, co2 } = this.forecast || {};
+			return grid || solar || co2;
+		},
 		showLogout() {
 			return isLoggedIn();
 		},
@@ -219,6 +234,10 @@ export default {
 			);
 			modal.show();
 		},
+		openForecastModal() {
+			const modal = Modal.getOrCreateInstance(document.getElementById("forecastModal"));
+			modal.show();
+		},
 		openNativeSettings() {
 			sendToApp({ type: "settings" });
 		},
diff --git a/assets/js/mixins/formatter.js b/assets/js/mixins/formatter.js
index 1d487e07..0153a154 100644
--- a/assets/js/mixins/formatter.js
+++ b/assets/js/mixins/formatter.js
@@ -187,6 +187,14 @@ export default {
         minute: "numeric",
       }).format(date);
     },
+    hourShort: function (date) {
+      const locale = this.$i18n?.locale;
+      // special: use shorter german format
+      if (locale === "de") return date.getHours();
+      return new Intl.DateTimeFormat(locale, {
+        hour: "numeric",
+      }).format(date);
+    },
     weekdayShort: function (date) {
       return new Intl.DateTimeFormat(this.$i18n?.locale, {
         weekday: "short",
diff --git a/assets/js/router.js b/assets/js/router.js
index a2f02be2..2e5202d3 100644
--- a/assets/js/router.js
+++ b/assets/js/router.js
@@ -61,6 +61,11 @@ export default function setupRouter(i18n) {
           };
         },
       },
+      {
+        path: "/energy",
+        component: () => import("./views/Energy.vue"),
+        props: true,
+      },
       {
         path: "/log",
         component: () => import("./views/Log.vue"),
diff --git a/assets/js/utils/forecast.ts b/assets/js/utils/forecast.ts
new file mode 100644
index 00000000..2ded3dbb
--- /dev/null
+++ b/assets/js/utils/forecast.ts
@@ -0,0 +1,70 @@
+export interface PriceSlot {
+	start: string;
+	end: string;
+	price: number;
+}
+
+export enum ForecastType {
+	Solar = "solar",
+	Price = "price",
+	Co2 = "co2",
+}
+
+export function aggregateEnergy(slots: PriceSlot[], ignorePast: boolean = false): number {
+	const now = new Date();
+	return slots.reduce((acc: number, { start, end, price: power }: PriceSlot) => {
+		let startDate = new Date(start);
+		const endDate = new Date(end);
+		if (ignorePast) {
+			if (endDate < now) {
+				return acc; // ignore past slots
+			}
+			if (startDate < now) {
+				startDate = now; // count this slot from now on
+			}
+		}
+		const hours = (endDate.getTime() - startDate.getTime()) / (1000 * 60 * 60); // convert ms to hours
+		const energy = power * hours; // Wh
+		return acc + energy;
+	}, 0);
+}
+
+// return the date in local YYYY-MM-DD format
+function toDayString(date: Date): string {
+	const year = date.getFullYear();
+	const month = String(date.getMonth() + 1).padStart(2, "0");
+	const day = String(date.getDate()).padStart(2, "0");
+	return `${year}-${month}-${day}`;
+}
+
+// return only slots that are on a given date, ignores slots that are in the past
+export function filterSlotsByDate(slots: PriceSlot[], dayString: string): PriceSlot[] {
+	const now = new Date();
+	return slots.filter(({ start, end }) => {
+		const isPast = new Date(end) < now;
+		const dateMatches = toDayString(new Date(start)) === dayString;
+		return !isPast && dateMatches;
+	});
+}
+
+// return the energy for a given day (0 = today, 1 = tomorrow, etc.)
+export function energyByDay(slots: PriceSlot[], day: number = 0): number {
+	const dayString = dayStringByOffset(day);
+	const daySlots = filterSlotsByDate(slots, dayString);
+	return aggregateEnergy(daySlots, true);
+}
+
+export function dayStringByOffset(day: number): string {
+	const date = new Date();
+	date.setDate(date.getDate() + day);
+	return toDayString(date);
+}
+
+// return the highest slot for a given day (0 = today, 1 = tomorrow, etc.)
+export function highestSlotIndexByDay(slots: PriceSlot[], day: number = 0): number {
+	const dayString = dayStringByOffset(day);
+	const daySlots = filterSlotsByDate(slots, dayString);
+	const sortedSlots = daySlots.sort((a, b) => b.price - a.price);
+	const highestSlot = sortedSlots[0] || {};
+	return slots.findIndex((slot) => slot.start === highestSlot.start);
+}
diff --git a/assets/js/views/App.vue b/assets/js/views/App.vue
index 2bed7068..a9656f66 100644
--- a/assets/js/views/App.vue
+++ b/assets/js/views/App.vue
@@ -4,6 +4,7 @@
 
 		<GlobalSettingsModal v-bind="globalSettingsProps" />
 		<BatterySettingsModal v-if="batteryModalAvailabe" v-bind="batterySettingsProps" />
+		<ForecastModal v-bind="forecastModalProps" />
 		<HelpModal />
 		<PasswordModal />
 		<LoginModal />
@@ -15,6 +16,7 @@
 import store from "../store";
 import GlobalSettingsModal from "../components/GlobalSettingsModal.vue";
 import BatterySettingsModal from "../components/BatterySettingsModal.vue";
+import ForecastModal from "../components/ForecastModal.vue";
 import OfflineIndicator from "../components/OfflineIndicator.vue";
 import PasswordModal from "../components/PasswordModal.vue";
 import LoginModal from "../components/LoginModal.vue";
@@ -37,6 +39,7 @@ export default {
 		GlobalSettingsModal,
 		HelpModal,
 		BatterySettingsModal,
+		ForecastModal,
 		PasswordModal,
 		LoginModal,
 		OfflineIndicator,
@@ -69,6 +72,9 @@ export default {
 		offlineIndicatorProps() {
 			return this.collectProps(OfflineIndicator, store.state);
 		},
+		forecastModalProps() {
+			return this.collectProps(ForecastModal, store.state);
+		},
 	},
 	watch: {
 		version: function (now, prev) {
diff --git a/assets/js/views/Energy.vue b/assets/js/views/Energy.vue
new file mode 100644
index 00000000..74e99a15
--- /dev/null
+++ b/assets/js/views/Energy.vue
@@ -0,0 +1,111 @@
+<template>
+	<div class="container px-4 safe-area-inset">
+		<TopHeader title="Energy Overview" />
+		<div class="alert alert-light mb-3">This page is work in progress.</div>
+		<div class="row">
+			<main class="col-12">
+				<div v-if="forecastAvailable">
+					<h3 class="fw-normal my-4">
+						{{ $t("forecast.modalTitle") }}
+					</h3>
+					<div class="d-flex justify-content-start">
+						<IconSelectGroup>
+							<IconSelectItem
+								v-for="type in types"
+								:key="type"
+								:active="selectedType === type"
+								:label="$t(`forecast.type.${type}`)"
+								hideLabelOnMobile
+								@click="updateType(type)"
+							>
+								<component :is="typeIcons[type]"></component>
+							</IconSelectItem>
+						</IconSelectGroup>
+					</div>
+
+					<ForecastChart
+						class="my-3"
+						:grid="forecast.grid"
+						:solar="forecast.solar"
+						:co2="forecast.co2"
+						:currency="currency"
+						:selected="selectedType"
+					/>
+				</div>
+				<div v-else>
+					<p>nothing to see here</p>
+				</div>
+			</main>
+		</div>
+	</div>
+</template>
+
+<script lang="ts">
+import { defineComponent } from "vue";
+import TopHeader from "../components/TopHeader.vue";
+import ForecastChart from "../components/ForecastChart.vue";
+import IconSelectItem from "../components/IconSelectItem.vue";
+import IconSelectGroup from "../components/IconSelectGroup.vue";
+import DynamicPriceIcon from "../components/MaterialIcon/DynamicPrice.vue";
+import { type PriceSlot } from "../utils/forecast";
+import formatter from "../mixins/formatter";
+import store from "../store";
+
+interface Forecast {
+	grid?: PriceSlot[];
+	solar?: PriceSlot[];
+	co2?: PriceSlot[];
+}
+
+export const TYPES = {
+	SOLAR: "solar",
+	PRICE: "price",
+	CO2: "co2",
+};
+
+export default defineComponent({
+	name: "Energy",
+	components: {
+		TopHeader,
+		IconSelectGroup,
+		IconSelectItem,
+		ForecastChart,
+	},
+	mixins: [formatter],
+	data() {
+		return {
+			selectedType: TYPES.PRICE,
+			types: Object.values(TYPES),
+		};
+	},
+	head() {
+		return { title: `${this.$t("sessions.title")} | evcc` };
+	},
+	computed: {
+		typeIcons() {
+			return {
+				[TYPES.SOLAR]: "shopicon-regular-sun",
+				[TYPES.PRICE]: DynamicPriceIcon,
+				[TYPES.CO2]: "shopicon-regular-eco1",
+			};
+		},
+		forecastAvailable() {
+			const { grid, solar, co2 } = this.forecast;
+			return grid || solar || co2;
+		},
+		forecast(): Forecast {
+			return store.state?.forecast || {};
+		},
+		currency() {
+			return store.state?.currency;
+		},
+	},
+	methods: {
+		updateType(type: string) {
+			this.selectedType = type;
+		},
+	},
+});
+</script>
+
+<style scoped></style>
diff --git a/charger/mennekes-compact.go b/charger/mennekes-compact.go
index 4b4c9b55..8a697a8a 100644
--- a/charger/mennekes-compact.go
+++ b/charger/mennekes-compact.go
@@ -2,7 +2,7 @@ package charger
 
 // LICENSE
 
-// Copyright (c) 2023 premultiply
+// Copyright (c) 2023-2025 premultiply
 
 // This module is NOT covered by the MIT license. All rights reserved.
 
@@ -87,6 +87,8 @@ func NewMennekesCompactFromConfig(ctx context.Context, other map[string]interfac
 	return NewMennekesCompact(ctx, cc.URI, cc.Device, cc.Comset, cc.Baudrate, cc.Settings.Protocol(), cc.ID, cc.Timeout)
 }
 
+//go:generate go tool decorate -f decorateMennekesCompact -b *MennekesCompact -r api.Charger -t "api.PhaseSwitcher,Phases1p3p,func(int) error"
+
 // NewMennekesCompact creates Mennekes charger
 func NewMennekesCompact(ctx context.Context, uri, device, comset string, baudrate int, proto modbus.Protocol, slaveID uint8, timeout time.Duration) (api.Charger, error) {
 	conn, err := modbus.NewConnection(uri, device, comset, baudrate, proto, slaveID)
@@ -110,10 +112,16 @@ func NewMennekesCompact(ctx context.Context, uri, device, comset string, baudrat
 		conn: conn,
 	}
 
+	// check phase switching support
+	var phasesS func(int) error
+	if b, err := wb.conn.ReadHoldingRegisters(mennekesRegPhaseOptionsHW, 1); err == nil && encoding.Uint16(b) == 2 {
+		phasesS = wb.phases1p3p
+	}
+
 	// failsafe
 	go wb.heartbeat(ctx, mennekesHeartbeatInterval)
 
-	return wb, err
+	return decorateMennekesCompact(wb, phasesS), err
 }
 
 func (wb *MennekesCompact) heartbeat(ctx context.Context, timeout time.Duration) {
@@ -265,10 +273,8 @@ func (wb *MennekesCompact) ChargeDuration() (time.Duration, error) {
 }
 */
 
-var _ api.PhaseSwitcher = (*MennekesCompact)(nil)
-
 // Phases1p3p implements the api.PhaseSwitcher interface
-func (wb *MennekesCompact) Phases1p3p(phases int) error {
+func (wb *MennekesCompact) phases1p3p(phases int) error {
 	var u uint16
 	if phases == 1 {
 		u = 1
diff --git a/charger/mennekes-compact_decorators.go b/charger/mennekes-compact_decorators.go
new file mode 100644
index 00000000..7ed712d5
--- /dev/null
+++ b/charger/mennekes-compact_decorators.go
@@ -0,0 +1,35 @@
+package charger
+
+// Code generated by github.com/evcc-io/evcc/cmd/tools/decorate.go. DO NOT EDIT.
+
+import (
+	"github.com/evcc-io/evcc/api"
+)
+
+func decorateMennekesCompact(base *MennekesCompact, phaseSwitcher func(int) error) api.Charger {
+	switch {
+	case phaseSwitcher == nil:
+		return base
+
+	case phaseSwitcher != nil:
+		return &struct {
+			*MennekesCompact
+			api.PhaseSwitcher
+		}{
+			MennekesCompact: base,
+			PhaseSwitcher: &decorateMennekesCompactPhaseSwitcherImpl{
+				phaseSwitcher: phaseSwitcher,
+			},
+		}
+	}
+
+	return nil
+}
+
+type decorateMennekesCompactPhaseSwitcherImpl struct {
+	phaseSwitcher func(int) error
+}
+
+func (impl *decorateMennekesCompactPhaseSwitcherImpl) Phases1p3p(p0 int) error {
+	return impl.phaseSwitcher(p0)
+}
diff --git a/charger/smaevcharger.go b/charger/smaevcharger.go
index 5b7ef852..4f6d24b9 100644
--- a/charger/smaevcharger.go
+++ b/charger/smaevcharger.go
@@ -236,15 +236,22 @@ var _ api.MeterEnergy = (*Smaevcharger)(nil)
 
 // TotalEnergy implements the api.MeterEnergy interface
 func (wb *Smaevcharger) TotalEnergy() (float64, error) {
-	val, err := wb.getMeasurement("Measurement.Metering.GridMs.TotWhIn")
-	return val / 1e3, err
+	res, err := wb.getMeasurement("Measurement.Metering.GridMs.TotWhIn")
+	if se := new(smaevcharger.ErrUnknownMeasurement); errors.As(err, &se) {
+		res, err = wb.getMeasurement("Measurement.Metering.GridMs.TotWhIn.ChaSta")
+	}
+	return res / 1e3, err
 }
 
 var _ api.Meter = (*Smaevcharger)(nil)
 
 // CurrentPower implements the api.Meter interface
 func (wb *Smaevcharger) CurrentPower() (float64, error) {
-	return wb.getMeasurement("Measurement.Metering.GridMs.TotWIn")
+	res, err := wb.getMeasurement("Measurement.Metering.GridMs.TotWIn")
+	if se := new(smaevcharger.ErrUnknownMeasurement); errors.As(err, &se) {
+		res, err = wb.getMeasurement("Measurement.Metering.GridMs.TotWIn.ChaSta")
+	}
+	return res, err
 }
 
 var _ api.ChargeRater = (*Smaevcharger)(nil)
@@ -252,6 +259,9 @@ var _ api.ChargeRater = (*Smaevcharger)(nil)
 // ChargedEnergy implements the api.ChargeRater interface
 func (wb *Smaevcharger) ChargedEnergy() (float64, error) {
 	res, err := wb.getMeasurement("Measurement.ChaSess.WhIn")
+	if se := new(smaevcharger.ErrUnknownMeasurement); errors.As(err, &se) {
+		res, err = wb.getMeasurement("Measurement.Metering.GridMs.TotWhIn.ChaSta")
+	}
 	return res / 1e3, err
 }
 
@@ -317,7 +327,7 @@ func (wb *Smaevcharger) getMeasurement(id string) (float64, error) {
 		}
 	}
 
-	return 0, fmt.Errorf("unknown measurement: %s", id)
+	return 0, &smaevcharger.ErrUnknownMeasurement{Measurement: id}
 }
 
 func (wb *Smaevcharger) getParameter(id string) (string, error) {
diff --git a/charger/smaevcharger/types.go b/charger/smaevcharger/types.go
index a9f3394c..fd0462b7 100644
--- a/charger/smaevcharger/types.go
+++ b/charger/smaevcharger/types.go
@@ -26,6 +26,14 @@ const (
 	ChargerManualLockDisabled = "5172"
 )
 
+type ErrUnknownMeasurement struct {
+	Measurement string
+}
+
+func (e *ErrUnknownMeasurement) Error() string {
+	return "unknown measurement: " + e.Measurement
+}
+
 // Measurements Data json Response structure
 type Measurements struct {
 	ChannelId   string `json:"channelId"`
diff --git a/core/loadpoint.go b/core/loadpoint.go
index a7971385..3f47f9fd 100644
--- a/core/loadpoint.go
+++ b/core/loadpoint.go
@@ -241,6 +241,14 @@ func NewLoadpointFromConfig(log *util.Logger, settings settings.Settings, other
 		lp.mode = api.ModeOff
 	}
 
+	if lp.Title != "" {
+		lp.setTitle(lp.Title)
+	}
+
+	if lp.Priority > 0 {
+		lp.setPriority(lp.Priority)
+	}
+
 	return lp, nil
 }
 
@@ -280,14 +288,6 @@ func (lp *Loadpoint) restoreSettings() {
 		return
 	}
 
-	// from yaml
-	if lp.Title != "" {
-		lp.setTitle(lp.Title)
-	}
-	if lp.Priority > 0 {
-		lp.setPriority(lp.Priority)
-	}
-
 	// deprecated yaml properties
 	if lp.Phases_ > 0 {
 		lp.log.WARN.Printf("ignoring deprecated phases: %d. please configure via UI", lp.Phases_)
diff --git a/i18n/de.toml b/i18n/de.toml
index 85355e2c..92607141 100644
--- a/i18n/de.toml
+++ b/i18n/de.toml
@@ -162,7 +162,7 @@ chargerPower22kw = "22 kW"
 chargerPower22kwHelp = "Verwendet einen Strombereich von 6 bis 32 A."
 chargerPowerCustom = "andere"
 chargerPowerCustomHelp = "Definiere einen eigenen Strombereich."
-chargerTypeLabel = "Ladeleistung"
+chargerTypeLabel = "Ladetyp"
 chargingTitle = "Laden"
 circuitLabel = "Stromkreis"
 circuitUnassigned = "nicht zugewiesen"
@@ -434,6 +434,31 @@ modalUpdateNow = "Jetzt installieren"
 modalUpdateStarted = "Starte die neue Version von evcc ..."
 modalUpdateStatusStart = "Installation gestartet:"
 
+[forecast]
+modalTitle = "Vorhersage"
+
+[forecast.co2]
+average = "Durchschnitt"
+lowestHour = "Sauberste Stunde"
+range = "Bereich"
+
+[forecast.price]
+average = "Durchschnitt"
+lowestHour = "Günstigste Stunde"
+range = "Bereich"
+
+[forecast.solar]
+dayAfterTomorrow = "Übermorgen"
+partly = "teilweise"
+remaining = "verbleibend"
+today = "Heute"
+tomorrow = "Morgen"
+
+[forecast.type]
+co2 = "CO₂"
+price = "Preis"
+solar = "Solar"
+
 [header]
 about = "Über"
 blog = "Blog"
@@ -524,6 +549,7 @@ batteryGridChargeActive = "Netzladen aktiv"
 batteryGridChargeLimit = "Netzladen wenn"
 batteryHold = "Batterie (gesperrt)"
 batteryTooltip = "{energy} von {total} ({soc})"
+forecastTooltip = "Vorhersage: erwartete Sonnenenergie heute"
 gridImport = "Netzbezug"
 homePower = "Verbrauch"
 loadpoints = "Ladepunkt | Ladepunkt | {count} Ladepunkte"
diff --git a/i18n/el.toml b/i18n/el.toml
index df106b92..ed2fb239 100644
--- a/i18n/el.toml
+++ b/i18n/el.toml
@@ -72,7 +72,7 @@ phaseVoltages = "Τάση L1, L2, L3"
 power = "Ισχύς"
 powerRange = "Ισχύς"
 range = "Αυτονομία"
-soc = "Κατάσταση φόρτισης (SoC)"
+soc = "Κατάσταση φόρτισης"
 socLimit = "Όριο"
 temp = "Θερμοκρασία"
 topic = "Topic"
@@ -84,11 +84,13 @@ description = "Διαμόρφωση που επιτρέπει στο evcc να 
 title = "EEBus"
 
 [config.form]
+deprecated = "καταργήθηκε"
 example = "Παράδειγμα"
 optional = "προαιρετικό"
 
 [config.general]
 cancel = "Ακύρωση"
+delete = "Διαγραφή"
 docsLink = "Δείτε την τεκμηρίωση."
 experimental = "Πειραματικό"
 hideAdvancedSettings = "Απόκρυψη ρυθμίσεων για προχωρημένους"
@@ -101,6 +103,7 @@ save = "Αποθήκευση"
 showAdvancedSettings = "Εμφάνιση ρυθμίσεων για προχωρημένους"
 telemetry = "Τηλεμετρία"
 title = "Τίτλος"
+validateSave = "Επικύρωση και αποθήκευση"
 
 [config.grid]
 title = "Μετρητής δικτύου"
@@ -133,10 +136,11 @@ addPvBattery = "Προσθήκη Φ/Β ενέργειας ή μπαταρίας"
 addVehicle = "Προσθήκη οχήματος"
 configured = "ρυθμίστηκε"
 edit = "επεξεργασία"
+name = "Όνομα"
 title = "Διαμόρφωση"
 unconfigured = "δεν έχει ρυθμιστεί"
 vehicles = "Τα Οχήματά μου"
-yaml = "Ορίζεται στο evcc.yaml. Δεν μεταβάλλεται από το UI"
+yaml = "Συσκευή που έχει οριστεί στο evcc.yaml δεν είναι προς επεξεργασία από εδώ."
 
 [config.messaging]
 description = "Λάβετε μηνύματα σχετικά με τις συνεδρίες φόρτισης."
@@ -209,13 +213,16 @@ system = "Σύστημα"
 vehicles = "Οχήματα"
 
 [config.sponsor]
-addToken = "Εισαγάγετε το sponsor token"
-changeToken = "Αλλαγή του sponsor token"
+addToken = "Εισαγάγετε το token"
+changeToken = "Αλλαγή του token"
 description = "Το μοντέλο χορηγίας μας βοηθά να διατηρήσουμε το έργο και να δημιουργήσουμε με βιώσιμο τρόπο νέες και συναρπαστικές λειτουργίες. Ως χορηγός έχετε πρόσβαση σε όλες τις εφαρμογές φορτιστή."
 descriptionToken = "Θα λάβετε το token από το {url}. Προσφέρουμε επίσης ένα προσωρινό token για δοκιμή."
-error = "Δεν είναι έγκυρο το sponsor token."
+error = "Το sponsor token δεν είναι έγκυρο ."
 labelToken = "Sponsor token"
 title = "Χορηγία"
+tokenRequired = "Πρέπει να εισάγετε ένα sponsor token για να μπορέσετε να δημιουργήσετε αυτή τη συσκευή."
+tokenRequiredLearnMore = "Μάθετε περισσότερα."
+trialToken = "Δοκιμαστικό token"
 
 [config.system]
 logs = "Καταγραφές"
diff --git a/i18n/en.toml b/i18n/en.toml
index c25e3279..052f24b3 100644
--- a/i18n/en.toml
+++ b/i18n/en.toml
@@ -433,6 +433,31 @@ modalUpdateNow = "Install now"
 modalUpdateStarted = "Starting the new version of evcc…"
 modalUpdateStatusStart = "Installation started:"
 
+[forecast]
+modalTitle = "Forecast"
+
+[forecast.co2]
+average = "Average"
+lowestHour = "Cleanest hour"
+range = "Range"
+
+[forecast.price]
+average = "Average"
+lowestHour = "Cheapest hour"
+range = "Range"
+
+[forecast.solar]
+dayAfterTomorrow = "Day after tomorrow"
+partly = "partly"
+remaining = "remaining"
+today = "Today"
+tomorrow = "Tomorrow"
+
+[forecast.type]
+co2 = "CO₂"
+price = "Price"
+solar = "Solar"
+
 [header]
 about = "About"
 blog = "Blog"
@@ -523,6 +548,7 @@ batteryGridChargeActive = "grid charging active"
 batteryGridChargeLimit = "grid charging when"
 batteryHold = "Battery (locked)"
 batteryTooltip = "{energy} of {total} ({soc})"
+forecastTooltip = "forecast: remaining solar production today"
 gridImport = "Grid use"
 homePower = "Consumption"
 loadpoints = "Charger| Charger | {count} chargers"
diff --git a/i18n/fr.toml b/i18n/fr.toml
index 196d7bdd..db682172 100644
--- a/i18n/fr.toml
+++ b/i18n/fr.toml
@@ -31,7 +31,7 @@ titleAdd = "Ajouter une batterie domestique"
 titleEdit = "Modifier batterie"
 
 [config.charge]
-titleAdd = "Ajouter mesure de charge"
+titleAdd = "Ajouter un compteur de charge"
 titleEdit = "Modifier le compteur de charge"
 
 [config.charger]
@@ -41,7 +41,7 @@ ocppHelp = "Copier cette adresse dans votre configuration de chargeurs."
 ocppLabel = "URL du serveur OCPP"
 switchsockets = "Prises commutables"
 template = "Fabricant"
-titleAdd = "Ajouter chargeur"
+titleAdd = "Ajouter un chargeur"
 titleEdit = "Modifier chargeur"
 
 [config.circuits]
@@ -50,7 +50,7 @@ title = "Gestion de la charge"
 
 [config.control]
 description = "Les valeurs par défaut sont généralement bonnes. Ne faites des changements que si vous savez ce que vous faites."
-descriptionInterval = "Intervalle de mise à jour (en seconde) entre chaque cycle de contrôle. Défini la fréquence à laquelle evcc va lire les valeurs des mesures, ajuster la puissance de charge et mettre à jour l’affichage. Une intervalle courte (< 30s) peut créer des oscillations et comportements indésirables."
+descriptionInterval = "Intervalle de mise à jour (en seconde) entre chaque cycle de contrôle. Défini la fréquence à laquelle evcc va lire les valeurs des compteurs, ajuster la puissance de charge et mettre à jour l’affichage. Une intervalle courte (< 30s) peut créer des oscillations et comportements indésirables."
 descriptionResidualPower = "Déplace le point d’opération de la boucle de contrôle. Si vous avez une batterie domestique, il est recommandé de définir une valeur de 100 W. Ainsi, la batterie aura une légère priorité par rapport à l’utilisation du réseau."
 labelInterval = "Intervalle de mise à jour"
 labelResidualPower = "Puissance résiduelle"
@@ -132,8 +132,8 @@ validateSave = "Valider & enregistrer"
 
 [config.grid]
 title = "Compteur du réseau"
-titleAdd = "Ajouter compteur pour réseau"
-titleEdit = "Modifier compteur pour réseau"
+titleAdd = "Ajouter un compteur pour réseau"
+titleEdit = "Modifier le compteur pour réseau"
 
 [config.hems]
 description = "Connecter evcc à un autre Système de Gestion d’Energie Domestique."
@@ -157,7 +157,7 @@ v2Support = "Revenir à InfluxDB 2.x"
 
 [config.loadpoint]
 addCharger = "Ajouter un chargeur"
-addMeter = "Ajouter une mesure dédiée pour le chargeur"
+addMeter = "Ajouter un compteur dédié pour le chargeur"
 cancel = "Annuler"
 chargerLabel = "Chargeur"
 chargerPower11kw = "11 kW"
@@ -176,8 +176,8 @@ defaultModeLabel = "Mode par défaut"
 delete = "Supprimer"
 electricalSubtitle = "En cas de doute, demandez à votre électricien."
 electricalTitle = "Électricité"
-energyMeterHelp = "Appareil de mesure supplémentaire si le chargeur n’en a pas un intégré."
-energyMeterLabel = "Mesure de l’énergie"
+energyMeterHelp = "Compteur supplémentaire si le chargeur n’en a pas un intégré."
+energyMeterLabel = "Compteur d’énergie"
 estimateLabel = "Interpoler le niveau de charge entre deux mises à jour via l’API"
 maxCurrentHelp = "Doit être supérieur au courant minimum."
 maxCurrentLabel = "Courant maximum"
@@ -305,8 +305,8 @@ B = "B (connecté)"
 C = "C (en charge)"
 
 [config.pv]
-titleAdd = "Ajouter un compteur pour le solaire"
-titleEdit = "Modifier le compteur pour le solaire"
+titleAdd = "Ajouter un compteur de production solaire"
+titleEdit = "Modifier le compteur de production solaire"
 
 [config.section]
 general = "Général"
@@ -367,7 +367,7 @@ online = "Véhicules avec API en ligne"
 save = "Enregistrer"
 scooter = "Trottinette"
 template = "Fabricant"
-titleAdd = "Ajouter le véhicule"
+titleAdd = "Ajouter un véhicule"
 titleEdit = "Modifier le véhicule"
 validateSave = "Valider & enregistrer"
 
diff --git a/i18n/hu.toml b/i18n/hu.toml
index fdb28477..aa6f59b5 100644
--- a/i18n/hu.toml
+++ b/i18n/hu.toml
@@ -89,11 +89,12 @@ power = "Teljesítmény"
 powerRange = "Teljesítmény"
 range = "Hatótáv"
 singlePhase = "Egyfázisú"
-soc = "SoC"
+soc = "Töltöttség"
 socLimit = "Limit"
 temp = "Hőmérséklet"
 topic = "Téma"
 url = "URL"
+vehicleLimitSoc = "Jármű limit"
 yes = "igen"
 
 [config.deviceValueChargeStatus]
@@ -106,6 +107,7 @@ description = "Konfiguráció ami engedélyezi az evcc-nek, hogy kommunikáljon
 title = "EEBus"
 
 [config.form]
+deprecated = "elavult"
 example = "Példa"
 optional = "opcionális"
 
@@ -232,10 +234,11 @@ addTariffs = "Tarifa hozzáadása"
 addVehicle = "Jármű hozzáadása"
 configured = "konfigurálva"
 edit = "szerkesztés"
+name = "Név"
 title = "Konfiguráció"
 unconfigured = "nincs konfigurálva"
 vehicles = "Járműveim"
-yaml = "Az evcc.yaml fájlban konfigurálva. Nem szerkeszthető az UI-ról."
+yaml = "Eszköz az evcc.yaml fájlból nem szerkeszthető."
 
 [config.messaging]
 description = "Értesítési üzenetek beállítása a töltési folyamatokról."
@@ -653,7 +656,7 @@ targetSoc = "Limit"
 temp = "Hőm."
 tempLimit = "Hőm. limit"
 unknown = "Vendég jármű"
-vehicleSoc = "Töltés"
+vehicleSoc = "Töltöttség"
 
 [main.vehicleSoc]
 charging = "tölt"
diff --git a/i18n/nl.toml b/i18n/nl.toml
index 5530fd68..09d92514 100644
--- a/i18n/nl.toml
+++ b/i18n/nl.toml
@@ -87,23 +87,32 @@ phaseVoltages = "Spanning L1, L2, L3"
 power = "Vermogen"
 powerRange = "Vermogen"
 range = "Actieradius"
+singlePhase = "Monofase"
 soc = "Batterijpercentage"
 socLimit = "Limiet"
 temp = "Temperatuur"
 topic = "Onderwerp"
 url = "URL"
+vehicleLimitSoc = "Voertuig limiet"
 yes = "ja"
 
+[config.deviceValueChargeStatus]
+A = "A (niet aangesloten)"
+B = "B (aangesloten)"
+C = "C (opladen)"
+
 [config.eebus]
 description = "Configuratie voor communicatie met andere EEBus apparaten."
 title = "EEBus"
 
 [config.form]
+deprecated = "verouderd"
 example = "Voorbeeld"
 optional = "optioneel"
 
 [config.general]
 cancel = "Annuleren"
+delete = "Wissen"
 docsLink = "Zie documentatie."
 experimental = "Experimenteel"
 hideAdvancedSettings = "Verberg geavanceerde instellingen"
@@ -116,6 +125,7 @@ save = "Opslaan"
 showAdvancedSettings = "Toon geavanceerde instellingen"
 telemetry = "Telemetrie"
 title = "Titel"
+validateSave = "Valideren & opslaan"
 
 [config.grid]
 title = "Netmeter"
@@ -142,6 +152,17 @@ title = "InfluxDB"
 v1Support = "Hulp nodig voor InfluxDB 1.x?"
 v2Support = "Terug naar InfluxDB 2.x"
 
+[config.loadpoint]
+cancel = "Annuleren"
+chargerLabel = "Oplader"
+chargerPower11kw = "11 kW"
+chargerPower11kwHelp = "Zal een stroom van 6 tot 16 A gebruiken."
+chargerPower22kw = "22 kW"
+chargerPower22kwHelp = "Zal een stroom van 6 tot 32 A gebruiken."
+chargerPowerCustom = "andere"
+defaultModeLabel = "Standaard mode"
+delete = "Wissen"
+
 [config.main]
 addLoadpoint = "Voeg laadpunt toe"
 addPvBattery = "Voeg zonnepaneel of batterij toe"
diff --git a/i18n/pt.toml b/i18n/pt.toml
index 8529bf29..3f5ff126 100644
--- a/i18n/pt.toml
+++ b/i18n/pt.toml
@@ -167,6 +167,40 @@ chargerPower22kwHelp = " Utilização de uma gama de corrente de 6 a 32 A."
 chargerPowerCustom = "outro"
 chargerPowerCustomHelp = "Definir um intervalo de corrente personalizado."
 chargerTypeLabel = "Tipo de carregador"
+chargingTitle = "Carregando"
+circuitLabel = "Circuito"
+circuitUnassigned = "não atribuído"
+defaultModeHelp = "Modo de carregamento ao conectar o veículo."
+defaultModeHelpKeep = "Mantém o último modo de carregamento selecionado."
+defaultModeLabel = "Modo predefinido"
+delete = "Apagar"
+electricalSubtitle = "Em caso de dúvida, pergunte ao seu eletricista."
+electricalTitle = "Elétrico"
+energyMeterHelp = "Contador adicional se o carregador não tiver um integrado."
+energyMeterLabel = "Contador de energia"
+estimateLabel = "Interpolar o nível de carga entre actualizações da API"
+maxCurrentHelp = "Deve ser superior à corrente mínima."
+maxCurrentLabel = "Corrente máxima"
+minCurrentHelp = "Só desça abaixo dos 6 A se souber o que está a fazer."
+minCurrentLabel = "Corrente mínima"
+noVehicles = "Nenhum veículo está configurado."
+phases1p = "Monofásico"
+phases3p = "Trifásico"
+phasesAutomatic = "Fases automáticas"
+phasesAutomaticHelp = "O seu carregador suporta a comutação automática entre o carregamento de 1 e 3 fases. No ecrã principal, pode ajustar o comportamento das fases durante o carregamento."
+phasesHelp = "Número de fases ligadas ao carregador."
+phasesLabel = "Phases"
+pollIntervalHelp = "Tempo entre as actualizações da API do veículo. Intervalos curtos podem descarregar a bateria do veículo."
+pollIntervalLabel = "Intervalo de atualização"
+pollModeAlways = "sempre"
+pollModeAlwaysHelp = "Solicite sempre actualizações de estado em intervalos regulares."
+pollModeCharging = "carregando"
+pollModeChargingHelp = "Solicitar actualizações do estado do veículo apenas durante o carregamento."
+pollModeConnected = "conectado"
+pollModeConnectedHelp = "Atualizar o estado do veículo em intervalos regulares quando ligado."
+pollModeLabel = "Comportamento de atualização"
+priorityHelp = "Os pontos de carregamento com maior prioridade têm acesso prioritário ao excedente solar."
+priorityLabel = "Prioridade"
 
 [config.main]
 addLoadpoint = "Adicionar ponto de recarga"
diff --git a/i18n/sl.toml b/i18n/sl.toml
index 071b8693..006a5abf 100644
--- a/i18n/sl.toml
+++ b/i18n/sl.toml
@@ -30,6 +30,20 @@ never = "samo z dovolj presežka."
 titleAdd = "Dodaj baterijo"
 titleEdit = "Uredi baterijo"
 
+[config.charge]
+titleAdd = "Dodaj števec polnjenja"
+titleEdit = "Urejanje merilnika polnjenja"
+
+[config.charger]
+chargers = "polnilnice za EV"
+generic = "Splošne integracije"
+ocppHelp = "Kopirajte ta naslov v konfiguracijo vaše polnilnice."
+ocppLabel = "URL strežnika OCPP"
+switchsockets = "Preklopne vtičnice"
+template = "Proizvajalec"
+titleAdd = "Dodaj polnilnik"
+titleEdit = "Uredi polnilec"
+
 [config.circuits]
 description = "Zagotavlja, da vsota vseh obremenitvenih točk, povezanih z vezjem, ne preseže konfiguriranih omejitev moči in toka. Vezja je mogoče ugnezditi za izgradnjo hierarhije."
 title = "Upravljanje obremenitve"
@@ -43,8 +57,9 @@ labelResidualPower = "Preostala moč"
 title = "Nadzor vedenja"
 
 [config.deviceValue]
+amount = "Znesek"
 broker = "Broker"
-bucket = "Bucket"
+bucket = "Vedro"
 capacity = "Kapaciteta"
 chargeStatus = "Status"
 chargeStatusA = "brez povezave"
@@ -64,32 +79,43 @@ energy = "Energija"
 feedinPrice = "Cena prejete energije iz omrežja"
 gridPrice = "Cena omrežja"
 hemsType = "Sistem"
+identifier = "RFID-Identifier"
 no = "ne"
 odometer = "Število kilometrov"
 org = "Organizacija"
 phaseCurrents = "Tok L1, L2, L3"
 phasePowers = "Moč L1, L2, L3"
 phaseVoltages = "Napetost L1, L2, L3"
+phases1p3p = "Fazno stikalo"
 power = "Moč"
 powerRange = "Moč"
 range = "Doseg"
-soc = "SoC"
+singlePhase = "Enofazni"
+soc = "Polnjenje"
 socLimit = "Omejitev"
 temp = "Temperatura"
 topic = "Topic"
 url = "URL"
+vehicleLimitSoc = "Omejitev vozila"
 yes = "da"
 
+[config.deviceValueChargeStatus]
+A = "A (brez povezave)"
+B = "B (povezan)"
+C = "C (polnjenje)"
+
 [config.eebus]
 description = "Konfiguracija, ki evcc omogoča komunikacijo z drugimi napravami EEBus."
 title = "EEBus"
 
 [config.form]
+deprecated = "deprecated"
 example = "Primer"
 optional = "neobvezno"
 
 [config.general]
 cancel = "Prekliči"
+delete = "Izbriši"
 docsLink = "Oglejte si dokumentacijo."
 experimental = "Eksperimentalno"
 hideAdvancedSettings = "Skrij napredne nastavitve"
@@ -587,6 +613,7 @@ scale3p = "Povečanje na 3-fazno napajanje kmalu..."
 targetChargeActive = "Ciljno polnjenje je aktivno, predviden konec {duration}."
 targetChargePlanned = "Ciljno polnjenje se začne ob {time}."
 targetChargeWaitForVehicle = "Ciljno polnjenje je pripravljeno. Čakam na vozilo ..."
+unknown = ""
 vehicleLimit = "Omejitev vozila."
 vehicleLimitReached = "Omejitev vozila dosežena."
 waitForVehicle = "Pripravljen. Čakam na vozilo..."
diff --git a/package-lock.json b/package-lock.json
index 6bd4fada..5f6b4eb0 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -27,7 +27,10 @@
         "bootstrap": "^5.2.2",
         "canvas-confetti": "^1.4.0",
         "chart.js": "^4.4.4",
+        "chartjs-adapter-dayjs-4": "^1.0.4",
+        "chartjs-plugin-datalabels": "^2.2.0",
         "countup.js": "^2.3.2",
+        "dayjs": "^1.11.13",
         "eslint": "^9.13.0",
         "eslint-config-prettier": "^10.0.1",
         "eslint-plugin-import": "^2.25.3",
@@ -4371,6 +4374,28 @@
         "pnpm": ">=8"
       }
     },
+    "node_modules/chartjs-adapter-dayjs-4": {
+      "version": "1.0.4",
+      "resolved": "https://registry.npmjs.org/chartjs-adapter-dayjs-4/-/chartjs-adapter-dayjs-4-1.0.4.tgz",
+      "integrity": "sha512-yy9BAYW4aNzPVrCWZetbILegTRb7HokhgospPoC3b5iZ5qdlqNmXts2KdSp6AqnjkPAp/YWyHDxLvIvwt5x81w==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=10"
+      },
+      "peerDependencies": {
+        "chart.js": ">=4.0.1",
+        "dayjs": "^1.9.7"
+      }
+    },
+    "node_modules/chartjs-plugin-datalabels": {
+      "version": "2.2.0",
+      "resolved": "https://registry.npmjs.org/chartjs-plugin-datalabels/-/chartjs-plugin-datalabels-2.2.0.tgz",
+      "integrity": "sha512-14ZU30lH7n89oq+A4bWaJPnAG8a7ZTk7dKf48YAzMvJjQtjrgg5Dpk9f+LbjCF6bpx3RAGTeL13IXpKQYyRvlw==",
+      "license": "MIT",
+      "peerDependencies": {
+        "chart.js": ">=3.0.0"
+      }
+    },
     "node_modules/check-error": {
       "version": "2.1.1",
       "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.1.tgz",
@@ -4718,6 +4743,12 @@
         "url": "https://github.com/sponsors/ljharb"
       }
     },
+    "node_modules/dayjs": {
+      "version": "1.11.13",
+      "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.13.tgz",
+      "integrity": "sha512-oaMBel6gjolK862uaPQOVTA7q3TZhuSvuMQAAglQDOWYO9A91IrAOUJEyKVlqJlHE0vq5p5UXxzdPfMH/x6xNg==",
+      "license": "MIT"
+    },
     "node_modules/debug": {
       "version": "4.4.0",
       "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.0.tgz",
diff --git a/package.json b/package.json
index c2ddf0d6..f3a325ab 100644
--- a/package.json
+++ b/package.json
@@ -34,7 +34,10 @@
     "bootstrap": "^5.2.2",
     "canvas-confetti": "^1.4.0",
     "chart.js": "^4.4.4",
+    "chartjs-adapter-dayjs-4": "^1.0.4",
+    "chartjs-plugin-datalabels": "^2.2.0",
     "countup.js": "^2.3.2",
+    "dayjs": "^1.11.13",
     "eslint": "^9.13.0",
     "eslint-config-prettier": "^10.0.1",
     "eslint-plugin-import": "^2.25.3",
diff --git a/packaging/docker/bin/entrypoint.sh b/packaging/docker/bin/entrypoint.sh
index 6ea4936b..29a6f627 100755
--- a/packaging/docker/bin/entrypoint.sh
+++ b/packaging/docker/bin/entrypoint.sh
@@ -6,10 +6,31 @@ HASSIO_OPTIONSFILE=/data/options.json
 
 if [ -f ${HASSIO_OPTIONSFILE} ]; then
 	CONFIG=$(grep -o '"config_file": "[^"]*' ${HASSIO_OPTIONSFILE} | grep -o '[^"]*$')
-	echo "Using config file: ${CONFIG}"
-
 	SQLITE_FILE=$(grep -o '"sqlite_file": "[^"]*' ${HASSIO_OPTIONSFILE} | grep -o '[^"]*$')
 
+	# Config File Migration
+	# If there is no config file found in '/config' we copy it from '/homeassistant' and rename the old config file to .migrated
+	if [ ! -f "${CONFIG}" ]; then
+		CONFIG_OLD=$(echo "${CONFIG}" | sed 's#^/config#/homeassistant#')
+		if [ -f "${CONFIG_OLD}" ]; then
+			mkdir -p "$(dirname "${CONFIG}")" && cp "${CONFIG_OLD}" "${CONFIG}"
+			mv "${CONFIG_OLD}" "${CONFIG_OLD}.migrated"
+			echo "Moving old config file '${CONFIG_OLD}' to new location '${CONFIG}', appending '.migrated' to old config file! Old file can safely be deleted by user."
+		fi
+	fi
+
+	# Database File Migration (optional, in case it is in /config)
+	# Only in case the user put her DB into the '/config' folder instead of default '/data' we will migrate it aswell
+	if [ "${SQLITE_FILE#/config}" != "${SQLITE_FILE}" ] && [ ! -f "${SQLITE_FILE}" ]; then
+		SQLITE_FILE_OLD=$(echo "${SQLITE_FILE}" | sed 's#^/config#/homeassistant#')
+		if [ -f "${SQLITE_FILE_OLD}" ]; then
+			mkdir -p "$(dirname "${SQLITE_FILE}")" && cp "${SQLITE_FILE_OLD}" "${SQLITE_FILE}"
+			mv "${SQLITE_FILE_OLD}" "${SQLITE_FILE_OLD}.migrated"
+			echo "Moving old db file '${SQLITE_FILE_OLD}' to new location '${SQLITE_FILE}', appending '.migrated' to old db file! Old file can safely be deleted by user."
+		fi
+	fi
+
+	echo "Using config file: ${CONFIG}"
 	if [ ! -f "${CONFIG}" ]; then
 		echo "Config not found. Please create a config under ${CONFIG}."
 		echo "For details see evcc documentation at https://github.com/evcc-io/evcc#readme."
diff --git a/templates/definition/charger/ocpp-mennekes-acu.yaml b/templates/definition/charger/ocpp-mennekes-acu.yaml
index b7e377ad..4ec83049 100644
--- a/templates/definition/charger/ocpp-mennekes-acu.yaml
+++ b/templates/definition/charger/ocpp-mennekes-acu.yaml
@@ -16,7 +16,7 @@ requirements:
     en: |
       To configure, open the web interface of the ACU.
       Setup, Backend, Communication Protocol: Open Charge Point Protocol v1.6J
-      Setup, Backend, Backend Server: The EVCC URL ws://<evcc-address>:8887/ must be entered here. Basic Authentication: Disabled. OCPP 1.6 Settings: Enable all options.
+      Setup, Backend, Backend Server: The evcc URL `ws://<evcc-address>:8887/` must be entered here. Basic Authentication: Disabled. OCPP 1.6 Settings: Enable all options.
   evcc: ["sponsorship", "skiptest"]
 params:
   - preset: ocpp
diff --git a/templates/definition/charger/smaevcharger.yaml b/templates/definition/charger/smaevcharger.yaml
index 4e0a196f..52839a22 100644
--- a/templates/definition/charger/smaevcharger.yaml
+++ b/templates/definition/charger/smaevcharger.yaml
@@ -2,7 +2,7 @@ template: smaevcharger
 products:
   - brand: SMA
     description:
-      generic: EV Charger
+      generic: EV Charger, eCharger
 capabilities: ["mA"]
 requirements:
   evcc: ["sponsorship"]
diff --git a/templates/definition/meter/homematic.yaml b/templates/definition/meter/homematic.yaml
index 846d0416..0a4bb372 100644
--- a/templates/definition/meter/homematic.yaml
+++ b/templates/definition/meter/homematic.yaml
@@ -4,7 +4,7 @@ products:
 group: switchsockets
 params:
   - name: usage
-    choice: ["pv", "charge"]
+    choice: ["grid", "pv", "charge"]
   - name: host
   - name: device
     description:
@@ -25,13 +25,12 @@ params:
     default: 6
     type: int
     required: true
-    advanced: true
     description:
-      en: Meter channel number (HMIP-PSM=6, HMIP-FSM+HMIP-FSM16=5)
-      de: Kanalnummer des Power-Meters (HMIP-PSM=6, HMIP-FSM+HMIP-FSM16=5)
+      en: Meter channel number (HMIP-PSM=6, HMIP-FSM+HMIP-FSM16=5, HM-ES-TX-WM=1)
+      de: Kanalnummer des Power- oder Netz-Meters (HMIP-PSM=6, HMIP-FSM+HMIP-FSM16=5, HM-ES-TX-WM=1)
     help:
-      en: Homematic meter channel number like shown in the CCU web user interface.
-      de: Kanalnummer des Messwertkanals, wie im CCU Webfrontend angezeigt.
+      en: Homematic meter channel number like shown after the device id separated with a colon in the CCU web user interface.
+      de: Kanalnummer des Messwertkanals, wie im CCU Webfrontend mit Doppelpunkt getrennt nach der Geräte Id angezeigt.
   - name: cache
     advanced: true
     default: 1s
diff --git a/templates/definition/meter/saj-h1.yaml b/templates/definition/meter/saj-h1.yaml
new file mode 100644
index 00000000..1dbd027e
--- /dev/null
+++ b/templates/definition/meter/saj-h1.yaml
@@ -0,0 +1,168 @@
+template: saj-h1
+products:
+  - brand: SAJ
+    description:
+      generic: H1 Series Hybrid Solar Inverter
+params:
+  - name: usage
+    choice: ["grid", "pv", "battery"]
+  - name: modbus
+    choice: ["tcpip"]
+  - name: timeout
+    default: 5s
+  - name: capacity
+    advanced: true
+render: |
+  type: custom
+  {{- if eq .usage "grid" }}
+  power:
+    source: modbus
+    {{- include "modbus" . | indent 2 }}
+    timeout: {{ .timeout }}
+    register:
+      address: 0x40A1 # SmartMeterTotalGridPowerWatt (undocumented)
+      type: holding
+      decode: int16
+  energy:
+    source: modbus
+    {{- include "modbus" . | indent 2 }}
+    timeout: {{ .timeout }}
+    register:
+      address: 0x40FD # Total_FeedInEnergy
+      type: holding
+      decode: uint32
+    scale: 0.01
+  {{- end }}
+  {{- if eq .usage "pv" }}
+  power:
+    source: modbus
+    {{- include "modbus" . | indent 2 }}
+    timeout: {{ .timeout }}
+    register:
+      address: 0x40A5 # TotalPVPower
+      type: holding
+      decode: int16
+  energy:
+    source: modbus
+    {{- include "modbus" . | indent 2 }}
+    timeout: {{ .timeout }}
+    register:
+      address: 0x40C5 # Total PVEnergy
+      type: holding
+      decode: uint32
+    scale: 0.01
+  {{- end }}
+  {{- if eq .usage "battery" }}
+  power:
+    source: modbus
+    {{- include "modbus" . | indent 2 }}
+    timeout: {{ .timeout }}
+    register:
+      address: 0x40A6 # TotalBatteryPower
+      type: holding
+      decode: int16
+  energy:
+    source: modbus
+    {{- include "modbus" . | indent 2 }}
+    timeout: {{ .timeout }}
+    register:
+      address: 0x40D5 # Total BatDisEnergy
+      type: holding
+      decode: uint32
+    scale: 0.01
+  soc:
+    source: modbus
+    {{- include "modbus" . | indent 2 }}
+    timeout: {{ .timeout }}
+    register:
+      address: 0x406F # BatEnergyPercent
+      type: holding
+      decode: uint16
+    scale: 0.01
+  batterymode:
+    source: switch
+    switch:
+    - case: 1 # normal
+      set:
+        source: sequence
+        set:
+        - source: const
+          value: 0 # self-use mode
+          set:
+            source: modbus
+            {{- include "modbus" . | indent 10 }}
+            timeout: {{ .timeout }}
+            register:
+              address: 0x3247 # AppMode
+              type: writeholding
+              decode: uint16
+    - case: 2 # hold
+      set:
+        source: sequence
+        set:
+        - source: const
+          value: 3 # passive mode
+          set:
+            source: modbus
+            {{- include "modbus" . | indent 10 }}
+            timeout: {{ .timeout }}
+            register:
+              address: 0x3247 # AppMode
+              type: writeholding
+              decode: uint16
+    - case: 3 # charge
+      set:
+        source: sequence
+        set:
+        - source: const
+          value: 1 # time-of-use mode
+          set:
+            source: modbus
+            {{- include "modbus" . | indent 10 }}
+            timeout: {{ .timeout }}
+            register:
+              address: 0x3247 # AppMode
+              type: writeholding
+              decode: uint16
+        - source: const
+          value: 1 # enable
+          set:
+            source: modbus
+            {{- include "modbus" . | indent 10 }}
+            timeout: {{ .timeout }}
+            register:
+              address: 0x3604 # Charge time enable control
+              type: writeholding
+              decode: uint16
+        - source: const
+          value: 0 # start time (00:00)
+          set:
+            source: modbus
+            {{- include "modbus" . | indent 10 }}
+            timeout: {{ .timeout }}
+            register:
+              address: 0x3606 # Battery first charging time (start)
+              type: writeholding
+              decode: uint16
+        - source: const
+          value: 0x173B # end time (23:59)
+          set:
+            source: modbus
+            {{- include "modbus" . | indent 10 }}
+            timeout: {{ .timeout }}
+            register:
+              address: 0x3607 # Battery first charging time (end)
+              type: writeholding
+              decode: uint16
+        - source: const
+          value: 0x7F64 # every day (0x7f) at 100% (0x64)
+          set:
+            source: modbus
+            {{- include "modbus" . | indent 10 }}
+            timeout: {{ .timeout }}
+            register:
+              address: 0x3608 # Battery first charging time (power)
+              type: writeholding
+              decode: uint16
+  capacity: {{ .capacity }} # kWh
+  {{- end }}
diff --git a/templates/definition/meter/solax.yaml b/templates/definition/meter/solax.yaml
index 66e6eb05..85964cb9 100644
--- a/templates/definition/meter/solax.yaml
+++ b/templates/definition/meter/solax.yaml
@@ -15,6 +15,10 @@ params:
   - name: modbus
     choice: ["rs485", "tcpip"]
     baudrate: 19200
+  - name: storageunit
+    type: int
+    default: 1
+    advanced: true
   - name: capacity
     advanced: true
   - name: maxacpower
@@ -27,7 +31,7 @@ render: |
   {{- if eq .usage "grid" }}
     source: modbus
     {{- include "modbus" . | indent 2 }}
-    register: # manual non-sunspec register configuration
+    register:
       address: 70 # 0x0046 feedin_power(meter)
       type: input
       decode: int32s
@@ -35,7 +39,7 @@ render: |
   energy:
     source: modbus
     {{- include "modbus" . | indent 2 }}
-    register: # manual non-sunspec register configuration
+    register:
       address: 74 # 0x004A consum_energy_total(meter)
       type: input
       decode: uint32s
@@ -46,20 +50,26 @@ render: |
     add:
     - source: modbus
       {{- include "modbus" . | indent 4 }}
-      register: # manual non-sunspec register configuration
+      register:
         address: 10 # 0x000A Powerdc1
         type: input
         decode: uint16
     - source: modbus
       {{- include "modbus" . | indent 4 }}
-      register: # manual non-sunspec register configuration
+      register:
         address: 11 # 0x000B Powerdc2
         type: input
         decode: uint16
+    - source: modbus
+      {{- include "modbus" . | indent 4 }}
+      register:
+        address: 292 # 0x0124 Powerdc3
+        type: input
+        decode: uint16
   energy:
     source: modbus
     {{- include "modbus" . | indent 2 }}
-    register: # manual non-sunspec register configuration
+    register:
       address: 148 # 0x0094 SolarEnergyTotal
       type: input
       decode: uint32s
@@ -69,8 +79,13 @@ render: |
   {{- if eq .usage "battery" }}
     source: modbus
     {{- include "modbus" . | indent 2 }}
-    register: # manual non-sunspec register configuration
+    register:
+      {{- if eq .storageunit "1" }}
       address: 22 # 0x0016 Batpower_Charge1
+      {{- end }}
+      {{- if eq .storageunit "2" }}
+      address: 297 # 0x0129 Batpower_Charge2
+      {{- end }}
       type: input
       decode: int16
     scale: -1
@@ -78,9 +93,22 @@ render: |
     source: modbus
     {{- include "modbus" . | indent 2 }}
     register:
-      address: 28 # 0x001C Battery Capacity
+      {{- if eq .storageunit "1" }}
+      address: 28 # 0x001C Battery 1 Capacity
+      {{- end }}
+      {{- if eq .storageunit "2" }}
+      address: 301 # 0x012D Battery 2 Capacity
+      {{- end }}
+      # address: 302 # 0x012E Battery Total Capacity
       type: input
       decode: uint16
+  # energy:
+  #   source: modbus
+  #   register:
+  #     address: 29 # 0x001D Battery Output Energy Total
+  #     type: input
+  #     decode: uint32s
+  #   scale: 0.1
   batterymode:
     source: switch
     switch:
diff --git a/templates/definition/tariff/forecast-solar.yaml b/templates/definition/tariff/forecast-solar.yaml
index 61067ed8..36799c9e 100644
--- a/templates/definition/tariff/forecast-solar.yaml
+++ b/templates/definition/tariff/forecast-solar.yaml
@@ -46,12 +46,13 @@ params:
     required: true
   - name: horizon
     description:
-      en: Horizon / terrain shadows
-      de: Horizont / Geländeschatten
+      en: Horizon
+      de: Horizont
     help:
-      en: Horizon line as comma separated list of angles in degrees. Leave empty for calculated horizon shading.
-      de: Horizontlinie als kommaseparierte Liste von Winkeln in Grad. Leer lassen für berechnete Horizontschattierung.
+      en: Simulates terrain shadows, [more information](https://doc.forecast.solar/horizon)
+      de: Simuliert Verschattung durch Gelände, [mehr Informationen](https://doc.forecast.solar/horizon)
     example: 0,0,15,30,45,60,60,60,45,30,15,0
+    advanced: true
   - name: apikey
     description:
       en: API key
@@ -64,7 +65,7 @@ render: |
     source: http
     uri: https://api.forecast.solar/{{ if .apikey }}{{ .apikey }}/{{ end }}estimate/{{ .lat }}/{{ .lon }}/{{ .dec }}/{{ .az }}/{{ .kwp }}?time=utc&full=1&resolution=60{{ if .horizon }}&horizon={{ .horizon }}{{ end }}
     jq: |
-      [ .result.watt_hours_period | to_entries.[] | {
+      [ .result.watts | to_entries.[] | {
         "start": (.key | strptime("%FT%T%z") | strftime("%FT%TZ")),
         "end":   (.key | strptime("%FT%T%z") | mktime+3600 | strftime("%FT%TZ")),
         "price": .value
diff --git a/templates/definition/vehicle/ford.yaml b/templates/definition/vehicle/ford.yaml
index ddab0d27..14004f1f 100644
--- a/templates/definition/vehicle/ford.yaml
+++ b/templates/definition/vehicle/ford.yaml
@@ -2,6 +2,10 @@ template: ford
 deprecated: true
 products:
   - brand: Ford
+requirements:
+  description:
+    de: "Hinweis: Ford hat kürzlich den API-Zugriff für seine Benutzer deaktiviert."
+    en: "Note: Ford has recently disabled API-access for their users."
 params:
   - preset: vehicle-base
   - name: vin
diff --git a/util/decoder.go b/util/decoder.go
index e6b806a6..04fd6266 100644
--- a/util/decoder.go
+++ b/util/decoder.go
@@ -47,10 +47,10 @@ func NewConfigError(err error) error {
 	return &ConfigError{err}
 }
 
-func (e *ConfigError) Error() string {
+func (e ConfigError) Error() string {
 	return e.err.Error()
 }
 
-func (e *ConfigError) Unwrap() error {
+func (e ConfigError) Unwrap() error {
 	return e.err
 }


Test patch:
diff --git a/assets/js/utils/forecast.test.ts b/assets/js/utils/forecast.test.ts
new file mode 100644
index 00000000..4930712a
--- /dev/null
+++ b/assets/js/utils/forecast.test.ts
@@ -0,0 +1,30 @@
+import { describe, expect, test, vi } from "vitest";
+import { aggregateEnergy, type PriceSlot } from "./forecast";
+
+const slots: PriceSlot[] = [
+	{ start: "2050-03-20T10:00:00+01:00", end: "2050-03-20T10:30:00+01:00", price: 2000 }, // 1kWh
+	{ start: "2050-03-20T10:30:00+01:00", end: "2050-03-20T11:00:00+01:00", price: 4000 }, // 2kWh
+	{ start: "2050-03-20T11:00:00+01:00", end: "2050-03-20T11:30:00+01:00", price: 3000 }, // 1.5kWh
+	{ start: "2050-03-20T11:30:00+01:00", end: "2050-03-20T12:00:00+01:00", price: 2000 }, // 1kWh
+	{ start: "2050-03-20T12:00:00+01:00", end: "2050-03-20T12:30:00+01:00", price: 0 }, // 0kWh
+	{ start: "2050-03-20T12:30:00+01:00", end: "2050-03-20T13:00:00+01:00", price: 0 }, // 0kWh
+];
+
+describe("aggregateEnergy", () => {
+	test("aggregates energy from power values for 30min slots", () => {
+		const result = aggregateEnergy(slots);
+		expect(result).toBeCloseTo(5500);
+	});
+	test("ignore slots in the past", () => {
+		// will ignore the first two slots
+		vi.setSystemTime("2050-03-20T11:00:00+01:00");
+		const result = aggregateEnergy(slots, true);
+		expect(result).toBeCloseTo(2500);
+	});
+	test("correctly split in-between slots", () => {
+		// start in the middle of the 4th slot
+		vi.setSystemTime("2050-03-20T11:45:00+01:00");
+		const result = aggregateEnergy(slots, true);
+		expect(result).toBeCloseTo(500);
+	});
+});