Repository: evcc-io/evcc
Pull request #9396 (2023-08-15 23:05:39)

Avoid Deadlocks and Ensure Thread Safety in the TeeAttacher
**Description**

This pull request addresses the deadlock issue and enhances the performance of the Tee implementation. The existing code had potential deadlock problems when a large number of requests were created. The goal was to handle requests more efficiently without imposing a hard limit on the number of requests.

**Changes Made**

- Added a mutex (sync.Mutex) to protect access to the recv slice, ensuring safe concurrent access.
- Moved parameter conversion outside the loop to prevent redundant conversions for each recipient iteration.
- Utilized a buffered channel for attaching recipients to prevent blocking and deadlocks.
- Restructured the Attach and Run methods to accommodate the changes and improve performance.

Example

Consider the following example demonstrating the enhanced behavior of the revised Tee implementation:

```go

package main

import (
	"fmt"
	"time"
	"util"
)

func main() {
	// Create a new Tee instance
	tee := &util.Tee{}

	// Attach two receivers to the Tee
	attach1 := tee.Attach()
	attach2 := tee.Attach()

	// Goroutine for Receiver 1
	go func() {
		for param := range attach1 {
			fmt.Println("Received by Attach1:", param)
		}
	}()

	// Goroutine for Receiver 2
	go func() {
		for param := range attach2 {
			fmt.Println("Received by Attach2:", param)
		}
	}()

	paramChannel := make(chan util.Param)

	// Start the Tee's Run function in a goroutine
	go tee.Run(paramChannel)

	// Send 20 data points to the Tee
	for i := 1; i <= 20; i++ {
		paramChannel <- util.Param{Val: i}
		time.Sleep(time.Millisecond * 100) // Simulate some delay
	}

	close(paramChannel)
	time.Sleep(time.Second) // Allow time for goroutines to finish
}
```


**Expected Behavior:**

- The example creates a Tee instance and attaches two receivers (attach1 and attach2) to it.
- The Run function distributes data from paramChannel to both receivers.
- Due to the buffered channel and optimized locking mechanism, the receivers can process data without blocking or causing deadlocks, even though 20 data points are sent.
- The enhanced mutex protection ensures safe concurrent access to the recv slice.

andig: The PR looks correct but it does not solve any problem in evcc afaikt. Tee is not used in any concurrent way and not intended to be used like that. Or do I miss something?

Patch:
diff --git a/assets/js/components/ChargingSessionModal.vue b/assets/js/components/ChargingSessionModal.vue
index 2ae882ef..8c799864 100644
--- a/assets/js/components/ChargingSessionModal.vue
+++ b/assets/js/components/ChargingSessionModal.vue
@@ -63,16 +63,15 @@
 									</td>
 								</tr>
 								<tr>
-									<th>
+									<th class="align-baseline">
 										{{ $t("sessions.energy") }}
 									</th>
 									<td>
-										{{
-											fmtKWh(
-												session.chargedEnergy * 1e3,
-												session.chargedEnergy >= 1
-											)
-										}}
+										{{ fmtKWh(chargedEnergy, chargedEnergy >= 1e3) }}
+										<div v-if="session.chargeDuration">
+											{{ fmtDurationNs(session.chargeDuration) }}
+											(~{{ fmtKw(avgPower) }})
+										</div>
 									</td>
 								</tr>
 								<tr v-if="session.solarPercentage != null">
@@ -81,9 +80,7 @@
 									</th>
 									<td>
 										{{ fmtNumber(session.solarPercentage, 1) }}% ({{
-											fmtKWh(
-												session.chargedEnergy * 10 * session.solarPercentage
-											)
+											fmtKWh(solarEnergy, solarEnergy >= 1e3)
 										}})
 									</td>
 								</tr>
@@ -195,6 +192,18 @@ export default {
 		vehicles: [Object],
 	},
 	emits: ["session-changed"],
+	computed: {
+		chargedEnergy: function () {
+			return this.session.chargedEnergy * 1e3;
+		},
+		avgPower: function () {
+			const hours = this.session.chargeDuration / 1e9 / 3600;
+			return this.chargedEnergy / hours;
+		},
+		solarEnergy: function () {
+			return this.chargedEnergy * (this.session.solarPercentage / 100);
+		},
+	},
 	methods: {
 		openSessionDetailsModal() {
 			const modal = Modal.getOrCreateInstance(document.getElementById("sessionDetailsModal"));
diff --git a/assets/js/components/CustomSelect.vue b/assets/js/components/CustomSelect.vue
index b214dbeb..3c1ee851 100644
--- a/assets/js/components/CustomSelect.vue
+++ b/assets/js/components/CustomSelect.vue
@@ -2,10 +2,10 @@
 	<label class="position-relative d-block">
 		<select :value="selected" class="custom-select" @change="change">
 			<option
-				v-for="{ name, value, count } in options"
+				v-for="{ name, value, count, disabled } in options"
 				:key="value"
 				:value="value"
-				:disabled="count === 0"
+				:disabled="count === 0 || disabled"
 			>
 				{{ text(name, count) }}
 			</option>
diff --git a/assets/js/components/Loadpoint.vue b/assets/js/components/Loadpoint.vue
index 0b171803..8762fda9 100644
--- a/assets/js/components/Loadpoint.vue
+++ b/assets/js/components/Loadpoint.vue
@@ -128,19 +128,13 @@
 				<LabelAndValue
 					v-else-if="chargeRemainingDurationInterpolated"
 					:label="$t('main.loadpoint.remaining')"
-					:value="`
-						${fmtShortDuration(chargeRemainingDurationInterpolated)}
-						${fmtShortDurationUnit(chargeRemainingDurationInterpolated, true)}
-					`"
+					:value="fmtDuration(chargeRemainingDurationInterpolated)"
 					align="end"
 				/>
 				<LabelAndValue
 					v-else
 					:label="$t('main.loadpoint.duration')"
-					:value="`
-						${fmtShortDuration(chargeDurationInterpolated)}
-						${fmtShortDurationUnit(chargeDurationInterpolated)}
-					`"
+					:value="fmtDuration(chargeDurationInterpolated)"
 					align="end"
 				/>
 			</div>
diff --git a/assets/js/components/TargetChargePlan.vue b/assets/js/components/TargetChargePlan.vue
index 0c9a950a..b17c4121 100644
--- a/assets/js/components/TargetChargePlan.vue
+++ b/assets/js/components/TargetChargePlan.vue
@@ -42,7 +42,7 @@ export default {
 	},
 	computed: {
 		planDuration() {
-			return this.fmtShortDuration(this.duration, true);
+			return this.fmtDuration(this.duration);
 		},
 		isCo2() {
 			return this.smartCostType === CO2_TYPE;
diff --git a/assets/js/components/VehicleStatus.vue b/assets/js/components/VehicleStatus.vue
index c1cd0cca..95529d07 100644
--- a/assets/js/components/VehicleStatus.vue
+++ b/assets/js/components/VehicleStatus.vue
@@ -89,7 +89,7 @@ export default {
 
 			if (this.pvTimerActive && !this.enabled && this.pvAction === "enable") {
 				return t("pvEnable", {
-					remaining: this.fmtShortDuration(this.pvRemainingInterpolated, true),
+					remaining: this.fmtDuration(this.pvRemainingInterpolated),
 				});
 			}
 
@@ -106,19 +106,19 @@ export default {
 
 			if (this.pvTimerActive && this.charging && this.pvAction === "disable") {
 				return t("pvDisable", {
-					remaining: this.fmtShortDuration(this.pvRemainingInterpolated, true),
+					remaining: this.fmtDuration(this.pvRemainingInterpolated),
 				});
 			}
 
 			if (this.phaseTimerActive) {
 				return t(this.phaseAction, {
-					remaining: this.fmtShortDuration(this.phaseRemainingInterpolated, true),
+					remaining: this.fmtDuration(this.phaseRemainingInterpolated),
 				});
 			}
 
 			if (this.guardTimerActive) {
 				return t("guard", {
-					remaining: this.fmtShortDuration(this.guardRemainingInterpolated, true),
+					remaining: this.fmtDuration(this.guardRemainingInterpolated),
 				});
 			}
 
diff --git a/assets/js/mixins/breakpoint.js b/assets/js/mixins/breakpoint.js
new file mode 100644
index 00000000..28ee43f9
--- /dev/null
+++ b/assets/js/mixins/breakpoint.js
@@ -0,0 +1,33 @@
+export default {
+  data() {
+    return {
+      breakpoint: "md",
+    };
+  },
+  methods: {
+    updateBreakpoint() {
+      const width = window.innerWidth;
+
+      if (width <= 575) {
+        this.breakpoint = "xs";
+      } else if (width <= 767) {
+        this.breakpoint = "sm";
+      } else if (width <= 991) {
+        this.breakpoint = "md";
+      } else if (width <= 1199) {
+        this.breakpoint = "lg";
+      } else if (width <= 1399) {
+        this.breakpoint = "xl";
+      } else {
+        this.breakpoint = "xxl";
+      }
+    },
+  },
+  mounted() {
+    this.updateBreakpoint();
+    window.addEventListener("resize", this.updateBreakpoint);
+  },
+  beforeDestroy() {
+    window.removeEventListener("resize", this.updateBreakpoint);
+  },
+};
diff --git a/assets/js/mixins/formatter.js b/assets/js/mixins/formatter.js
index cf6cb1dd..f86cbede 100644
--- a/assets/js/mixins/formatter.js
+++ b/assets/js/mixins/formatter.js
@@ -72,19 +72,13 @@ export default {
     fmtUnit: function (val) {
       return Math.abs(val) >= this.fmtLimit ? "k" : "";
     },
-    fmtDuration: function (d) {
-      if (d <= 0 || d == null) {
-        return "—";
-      }
-      var seconds = "0" + (d % 60);
-      var minutes = "0" + (Math.floor(d / 60) % 60);
-      var hours = "" + Math.floor(d / 3600);
-      if (hours.length < 2) {
-        hours = "0" + hours;
-      }
-      return hours + ":" + minutes.substr(-2) + ":" + seconds.substr(-2);
+    fmtNumberToLocale(val, pad = 0) {
+      return val.toLocaleString(this.$i18n.locale).padStart(pad, "0");
     },
-    fmtShortDuration: function (duration = 0, withUnit = false) {
+    fmtDurationNs(duration = 0, withUnit = true, minUnit = "s") {
+      return this.fmtDuration(duration / 1e9, withUnit, minUnit);
+    },
+    fmtDuration: function (duration = 0, withUnit = true, minUnit = "s") {
       if (duration <= 0) {
         return "—";
       }
@@ -93,32 +87,22 @@ export default {
       var minutes = Math.floor(duration / 60) % 60;
       var hours = Math.floor(duration / 3600);
       var result = "";
-      if (hours >= 1) {
-        result = hours + ":" + `${minutes}`.padStart(2, "0");
-      } else if (minutes >= 1) {
-        result = minutes + ":" + `${seconds}`.padStart(2, "0");
+      let unit = "";
+      if (hours >= 1 || minUnit === "h") {
+        result = `${this.fmtNumberToLocale(hours)}:${this.fmtNumberToLocale(minutes, 2)}`;
+        unit = "h";
+      } else if (minutes >= 1 || minUnit === "m") {
+        result = `${this.fmtNumberToLocale(minutes)}:${this.fmtNumberToLocale(seconds, 2)}`;
+        unit = "m";
       } else {
-        result = `${seconds}`;
+        result = `${this.fmtNumberToLocale(seconds)}`;
+        unit = "s";
       }
       if (withUnit) {
-        result += this.fmtShortDurationUnit(duration);
+        result += `\u202F${unit}`;
       }
       return result;
     },
-    fmtShortDurationUnit: function (duration = 0) {
-      if (duration <= 0) {
-        return "";
-      }
-      var minutes = Math.floor(duration / 60) % 60;
-      var hours = Math.floor(duration / 3600);
-      if (hours >= 1) {
-        return "h";
-      }
-      if (minutes >= 1) {
-        return "m";
-      }
-      return "s";
-    },
     fmtDayString: function (date) {
       const YY = `${date.getFullYear()}`;
       const MM = `${date.getMonth() + 1}`.padStart(2, "0");
diff --git a/assets/js/settings.js b/assets/js/settings.js
index 0ce271dd..1af602b2 100644
--- a/assets/js/settings.js
+++ b/assets/js/settings.js
@@ -6,6 +6,7 @@ const SETTINGS_UNIT = "settings_unit";
 const SETTINGS_HIDDEN_FEATURES = "settings_hidden_features";
 const SETTINGS_ENERGYFLOW_DETAILS = "settings_energyflow_details";
 const SESSION_INFO = "session_info";
+const SESSION_COLUMNS = "session_columns";
 
 function read(key) {
   return window.localStorage[key];
@@ -36,7 +37,8 @@ function saveBool(key) {
 }
 
 function readArray(key) {
-  return (read(key) || "").split(",");
+  const value = read(key);
+  return value ? value.split(",") : [];
 }
 
 function saveArray(key) {
@@ -53,6 +55,7 @@ const settings = reactive({
   hiddenFeatures: readBool(SETTINGS_HIDDEN_FEATURES),
   energyflowDetails: readBool(SETTINGS_ENERGYFLOW_DETAILS),
   sessionInfo: readArray(SESSION_INFO),
+  sessionColumns: readArray(SESSION_COLUMNS),
 });
 
 watch(() => settings.locale, save(SETTINGS_LOCALE));
@@ -61,5 +64,6 @@ watch(() => settings.unit, save(SETTINGS_UNIT));
 watch(() => settings.hiddenFeatures, saveBool(SETTINGS_HIDDEN_FEATURES));
 watch(() => settings.energyflowDetails, saveBool(SETTINGS_ENERGYFLOW_DETAILS));
 watch(() => settings.sessionInfo, saveArray(SESSION_INFO));
+watch(() => settings.sessionColumns, saveArray(SESSION_COLUMNS));
 
 export default settings;
diff --git a/assets/js/views/ChargingSessions.vue b/assets/js/views/ChargingSessions.vue
index b62c2356..4060ec5c 100644
--- a/assets/js/views/ChargingSessions.vue
+++ b/assets/js/views/ChargingSessions.vue
@@ -135,35 +135,27 @@
 									</div>
 								</th>
 								<th
-									v-for="column in columns"
+									v-for="(column, index) in columnsPerBreakpoint"
 									:key="column.name"
 									scope="col"
 									:data-testid="`sessions-head-${column.name}`"
-									class="align-top text-end d-none d-md-table-cell"
+									class="align-top text-end"
 								>
-									{{ $t(`sessions.${column.name}`) }}<br />
-									<div class="text-gray fw-normal">{{ column.unit }}</div>
-								</th>
-								<th
-									scope="col"
-									class="align-top text-end d-md-none"
-									data-testid="sessions-head-mobile"
-								>
-									<span v-if="columns.length === 1">
-										{{ $t(`sessions.${mobileColumn.name}`) }}
-									</span>
 									<CustomSelect
-										v-else
-										:selected="selectedMobileColumn"
-										:options="mobileColumnOptions"
-										data-testid="mobile-column"
-										@change="changeMobileColumn"
+										v-if="tooMuchColumns"
+										:selected="column.name"
+										:options="columnOptions"
+										data-testid="column"
+										@change="selectColumnPosition(index, $event.target.value)"
 									>
 										<span class="text-decoration-underline">
-											{{ $t(`sessions.${mobileColumn.name}`) }}
+											{{ $t(`sessions.${column.name}`) }}
 										</span>
 									</CustomSelect>
-									<div class="text-gray fw-normal">{{ mobileColumn.unit }}</div>
+									<span v-else>
+										{{ $t(`sessions.${column.name}`) }}
+									</span>
+									<div class="text-gray fw-normal">{{ column.unit }}</div>
 								</th>
 							</tr>
 						</thead>
@@ -184,21 +176,14 @@
 								></th>
 								<th scope="col" class="d-md-none"></th>
 								<th
-									v-for="column in columns"
+									v-for="column in columnsPerBreakpoint"
 									:key="column.name"
 									:data-testid="`sessions-foot-${column.name}`"
 									scope="col"
-									class="align-top text-end d-none d-md-table-cell"
+									class="align-top text-end"
 								>
 									{{ column.format(column.total) }}
 								</th>
-								<th
-									scope="col"
-									data-testid="sessions-foot-mobile"
-									class="align-top text-end d-md-none"
-								>
-									{{ mobileColumn.format(mobileColumn.total) }}
-								</th>
 							</tr>
 						</tfoot>
 						<tbody>
@@ -223,26 +208,15 @@
 									<div>{{ session.vehicle }}</div>
 								</td>
 								<td
-									v-for="column in columns"
+									v-for="column in columnsPerBreakpoint"
 									:key="column.name"
-									class="text-end d-none d-md-table-cell"
+									class="text-end"
 								>
 									<span v-if="column.value(session) === null" class="text-gray">
 										-
 									</span>
 									<span v-else>{{ column.format(column.value(session)) }}</span>
 								</td>
-								<td class="text-end d-md-none">
-									<span
-										v-if="mobileColumn.value(session) === null"
-										class="text-gray"
-									>
-										-
-									</span>
-									<span v-else>{{
-										mobileColumn.format(mobileColumn.value(session))
-									}}</span>
-								</td>
 							</tr>
 						</tbody>
 					</table>
@@ -287,11 +261,22 @@ import api from "../api";
 import store from "../store";
 import CustomSelect from "../components/CustomSelect.vue";
 import ChargingSessionModal from "../components/ChargingSessionModal.vue";
+import breakpoint from "../mixins/breakpoint";
+import settings from "../settings";
+
+const COLUMNS_PER_BREAKPOINT = {
+	xs: 1,
+	sm: 2,
+	md: 3,
+	lg: 4,
+	xl: 5,
+	xxl: 6,
+};
 
 export default {
 	name: "ChargingSessions",
 	components: { TopNavigation, ChargingSessionModal, CustomSelect },
-	mixins: [formatter],
+	mixins: [formatter, breakpoint],
 	props: {
 		notifications: Array,
 		month: { type: Number, default: () => new Date().getMonth() + 1 },
@@ -303,7 +288,7 @@ export default {
 		return {
 			sessions: [],
 			selectedSessionId: undefined,
-			selectedMobileColumn: undefined,
+			selectedColumns: settings.sessionColumns,
 		};
 	},
 	computed: {
@@ -322,6 +307,9 @@ export default {
 		filteredSessions() {
 			return this.currentSessions.filter(this.filterByLoadpoint).filter(this.filterByVehicle);
 		},
+		maxColumns() {
+			return COLUMNS_PER_BREAKPOINT[this.breakpoint] || 1;
+		},
 		columns() {
 			const columns = [
 				{
@@ -359,6 +347,26 @@ export default {
 					value: (session) => session.co2PerKWh,
 					format: (value) => this.fmtNumber(value, 0),
 				},
+				{
+					name: "chargeDuration",
+					unit: "h:mm",
+					total: this.chargeDuration,
+					value: (session) => session.chargeDuration,
+					format: (value) => this.fmtDurationNs(value, false, "h"),
+				},
+				{
+					name: "avgPower",
+					unit: "kW",
+					total: this.avgPower,
+					value: (session) => {
+						if (session.chargedEnergy && session.chargeDuration) {
+							return session.chargedEnergy / this.nsToHours(session.chargeDuration);
+						}
+						return null;
+					},
+					format: (value) =>
+						value ? this.fmtKw(value * 1e3, true, false, 1) : undefined,
+				},
 			];
 			// only columns with values are shown
 			return columns.filter((column) => {
@@ -366,15 +374,33 @@ export default {
 				return this.currentSessions.some((s) => column.value(s));
 			});
 		},
-		mobileColumn() {
-			const column = this.columns.find((column) => column.name === this.selectedMobileColumn);
-			return column || this.columns[0];
+		tooMuchColumns() {
+			return this.columns.length > this.maxColumns;
+		},
+		sortedColumns() {
+			const columns = [...this.columns];
+			let sorted = [];
+			for (let name of this.selectedColumns) {
+				if (!name && columns.length) {
+					sorted.push(columns.shift());
+				} else if (columns.some((c) => c.name === name)) {
+					const column = columns.find((c) => c.name === name);
+					sorted.push(column);
+					let index = columns.indexOf(column);
+					columns.splice(index, 1);
+				}
+			}
+			return sorted.concat(columns);
+		},
+		columnsPerBreakpoint() {
+			return this.sortedColumns.slice(0, this.maxColumns);
 		},
-		mobileColumnOptions() {
+		columnOptions() {
 			return this.columns.map((column) => {
 				return {
 					name: this.$t(`sessions.${column.name}`),
 					value: column.name,
+					disabled: this.columnsPerBreakpoint.find((c) => c.name === column.name),
 				};
 			});
 		},
@@ -409,9 +435,28 @@ export default {
 		chargedEnergy() {
 			return this.filteredSessions.reduce((total, s) => total + s.chargedEnergy, 0);
 		},
+		chargeDuration() {
+			return this.filteredSessions.reduce((total, s) => total + s.chargeDuration, 0);
+		},
 		price() {
 			return this.filteredSessions.reduce((total, s) => total + s.price, 0);
 		},
+		avgPower() {
+			const { energy, hours } = this.filteredSessions
+				.filter((s) => s.chargedEnergy && s.chargeDuration)
+				.reduce(
+					(total, s) => {
+						total.energy += s.chargedEnergy;
+						total.hours += this.nsToHours(s.chargeDuration);
+						return total;
+					},
+					{ energy: 0, hours: 0 }
+				);
+			if (energy && hours) {
+				return energy / hours;
+			}
+			return null;
+		},
 		showVehicles() {
 			return this.hasMultipleVehicles || this.vehicleFilter;
 		},
@@ -448,7 +493,10 @@ export default {
 					}),
 					{ emittedCo2: 0, chargedEnergy: 0 }
 				);
-			return total.emittedCo2 / total.chargedEnergy;
+			if (total.chargedEnergy && total.emittedCo2) {
+				return total.emittedCo2 / total.chargedEnergy;
+			}
+			return null;
 		},
 		solarPercentage() {
 			const total = this.filteredSessions
@@ -542,6 +590,9 @@ export default {
 		this.loadSessions();
 	},
 	methods: {
+		nsToHours(ns) {
+			return ns / 1e9 / 3600;
+		},
 		filterByLoadpoint(session) {
 			return !this.loadpointFilter || session.loadpoint === this.loadpointFilter;
 		},
@@ -558,8 +609,9 @@ export default {
 				.filter(this.filterByVehicle)
 				.filter((s) => !loadpoint || s.loadpoint === loadpoint).length;
 		},
-		changeMobileColumn(event) {
-			this.selectedMobileColumn = event.target.value;
+		selectColumnPosition(index, value) {
+			this.selectedColumns[index] = value;
+			settings.sessionColumns = [...this.selectedColumns];
 		},
 		changeLoadpointFilter(event) {
 			const loadpoint = event.target.value || undefined;
diff --git a/cmd/configure/localization/en.toml b/cmd/configure/localization/en.toml
index f6b17843..80f8d1d7 100644
--- a/cmd/configure/localization/en.toml
+++ b/cmd/configure/localization/en.toml
@@ -1,4 +1,4 @@
-Intro = "The next steps will guide you through the creation of a configuration file for evcc.\nPlease note that this process cannot cover all possible scenarios.\nYou can cancel the process by pressing CTRL-C anytime.\n\nNOTE: This functionality is in an experimental state!\n  This means that the created configuration file may not work\n  in a future update of evcc and would need to be recreated.\n  We are looking forward to your feedback at https://github.com/evcc-io/evcc/discussions/\n\nLet's go:"
+Intro = "The next steps will guide you through the creation of a configuration file for evcc.\nPlease note that this process cannot cover all possible scenarios.\nYou can cancel the process by pressing CTRL-C anytime.\n\nNOTE: This functionality is in an experimental state!\n  This means that the configuration file created may not work\n  in a future update of evcc and would need to be recreated.\n  We are looking forward to your feedback at https://github.com/evcc-io/evcc/discussions/\n\nLet's go:"
 Flow_Mode = "In which mode should this process guide you through the configuration?"
 Flow_Mode_Standard = "Standard mode (As simple and quick as possible)"
 Flow_Mode_Advanced = "Advanced mode (Ask more details, requires technical know-how)"
@@ -11,7 +11,7 @@ Flow_SingleDevice_Setup = "- Setup a device"
 Flow_SingleDevice_Select = "Choose one of the following device categories"
 Flow_SingleDevice_Config = "The configuration:"
 Flow_SMAHems_Setup = "- Setup SMA HEMS"
-Flow_SMAHems_Add = "Do you want to report your wallboxes to the SMA Home Manager anbinden, so that it can consider them e.g. for controlling the in-house battery?"
+Flow_SMAHems_Add = "Do you want to connect your chargers to SMA Home Manager, so that it can consider them e.g. for controlling the in-house battery?"
 ItemNotPresent = "My device is not in this list"
 AddDeviceInCategory = "Do you want to add {{ .Article }} {{ .Category }}?"
 AddAnotherDeviceInCategory = "Do you want to add {{ .Additional }} {{ .Category }}?"
@@ -21,34 +21,34 @@ Error = "Error: {{ .Error }}"
 Error_ItemNotPresent = "Device not present"
 Error_DeviceNotValid = "Device does not work"
 Error_EEBUS_Certificate_Create = "Could not create the EEBUS certificate"
-Error_EEBUS_Certificate_Use = "Could not process teh generated EEBUS certificate"
+Error_EEBUS_Certificate_Use = "Could not process the generated EEBUS certificate"
 File_Exists = "The file {{ .FileName }} already exists. Do you want to replace it?"
 File_Permissions = "The file {{ .FileName }} already exists and can not be overwritten."
 File_NewFilename = "Please provide a new filename"
 File_Error_SaveFailed = "The configuration could not be saved in the file {{ .FileName }}"
 File_SaveSuccess = "The configuration was successfully saved in the file {{ .FileName }}"
 Choose = "Choose"
-Category_ChargerTitle = "wallbox"
+Category_ChargerTitle = "charger"
 Category_ChargerArticle = "a"
-Category_ChargerAdditional = "one more"
+Category_ChargerAdditional = "another"
 Category_SystemTitle = "integrated PV system"
 Category_SystemArticle = "an"
-Category_SystemAdditional = "one more"
+Category_SystemAdditional = "another"
 Category_GridMeterTitle = "grid meter"
 Category_GridMeterArticle = "a"
-Category_GridMeterAdditional = "one more"
+Category_GridMeterAdditional = "another"
 Category_PVMeterTitle = "PV inverter (or corresponding meter)"
 Category_PVMeterArticle = "a"
-Category_PVMeterAdditional = "one more"
+Category_PVMeterAdditional = "another"
 Category_BatteryMeter = "battery inverter (or corresponding meter)"
 Category_BatteryMeterArticle = "a"
-Category_BatteryMeterAdditional = "one more"
+Category_BatteryMeterAdditional = "another"
 Category_ChargeMeterTitle = "charge meter"
 Category_ChargeMeterArticle = "a"
-Category_ChargeMeterAdditional = "one more"
-Category_VehicleTitle = "vehicles"
+Category_ChargeMeterAdditional = "another"
+Category_VehicleTitle = "vehicle"
 Category_VehicleArticle = "a"
-Category_VehicleAdditional = "one more"
+Category_VehicleAdditional = "another"
 TestingDevice_Title = "Testing the configuration of {{ .Device }} ..."
 TestingDevice_TitleUsage = "Testing the {{ .Usage }} configuration of {{ .Device }} ..."
 TestingDevice_RepeatStep = "Do you want to repeat choosing a device and configuring it?"
@@ -57,45 +57,45 @@ TestingDevice_AddFailedUsage = "Testing of the {{ .Usage }} configuration of {{
 TestingMQTTFailed = "Testing the MQTT configuration failed. Do you want to repeat its configuration?"
 Requirements_Title = "The device has the following requirements:"
 Requirements_More = "Additional information:"
-Requirements_Sponsorship_Title = "This device requires an evcc sponsorship. Check the following link for what this is and how it works: https://docs.evcc.io/docs/sponsorship"
+Requirements_Sponsorship_Title = "This device requires evcc sponsorship. Check the following link for what this is and how it works: https://docs.evcc.io/docs/sponsorship"
 Requirements_Sponsorship_Optional_Title = "The development of evcc can be supported via sponsoring. Check the following link for what this is and how it works: https://docs.evcc.io/docs/sponsorship"
-Requirements_Sponsorship_Feature_Title = "To use this feature a evcc sponsorship is required. Check the following link for what this is and how it works: https://docs.evcc.io/docs/sponsorship"
+Requirements_Sponsorship_Feature_Title = "To use this feature evcc sponsorship is required. Check the following link for what this is and how it works: https://docs.evcc.io/docs/sponsorship"
 Requirements_Sponsorship_Token = "Are you already a sponsor?"
-Requirements_Sponsorship_Token_Input = "Please enter the sponsortoken"
+Requirements_Sponsorship_Token_Input = "Please enter the sponsorship token"
 Requirements_EEBUS_Cert_Error = "Error: The EEBUS certificate couldn't be created"
-Requirements_EEBUS_Pairing = "You selected a wallbox, which will be accessed via the EEBUS protocol.\nFor that the wallbox needs to be connected to evcc. This can usually be done in the web interface of the wallbox.\nPlease press the enter key once you started the process."
+Requirements_EEBUS_Pairing = "You selected a charger which will be accessed via the EEBUS protocol.\nFor that, the charger must be connected to evcc. This can usually be done in the web interface of the charger.\nPlease press the enter key once you have started the process."
 Config_Title = "Please provide the following settings:"
 Config_ModbusInterface = "Choose the ModBus interface"
 Config_AddAnotherValue = "Do you want to add another value?"
 Config_Yes = "Yes"
 Config_No = "No"
-Cancel = "The configuration was cancelled.\n\nIf this guided configuration process doesn't work for you yet, please try the manual configuration. You can find more details about that on our website: https://docs.evcc.io/docs/\n"
+Cancel = "The configuration was cancelled.\n\nIf this guided configuration process doesn't work for you, please try the manual configuration. You can find more details about that on our website: https://docs.evcc.io/docs/\n"
 InputError = "An input error occurred:"
-Value_Choice = "Chose:"
+Value_Choice = "Choose:"
 Value_Help = "Help:"
 Value_Required = "required"
 Value_Optional = "optional"
 Value_Sample = "Example"
 ValueError_Invalid = "This value is invalid"
-ValueError_Used = "This value is aready in use."
+ValueError_Used = "This value is already in use."
 ValueError_Empty = "The value may not be empty."
-ValueError_Float = "The value has to be a number."
-ValueError_Number = "The value has to be an integer."
+ValueError_Float = "The value must be a number."
+ValueError_Number = "The value must be an integer."
 ValueError_NumberLowerThanMin = "The value must be bigger or equal to {{ .Min }}."
 ValueError_NumberBiggerThanMax = "The value must be smaller or equal to {{ .Max }}."
-ValueError_Duration = "The value has to provide a duration. For example: 1s, 1m, 1h"
+ValueError_Duration = "The value must include a duration. For example: 1s, 1m, 1h"
 Device_Configure = "Configuration"
 Device_Added = "was successfully added."
-Loadpoint_Setup = "- Setup loadpoint(s)"
+Loadpoint_Setup = "- Setup loadpoint(s), i.e. group of colocated chargers"
 Loadpoint_Title = "Loadpoint title"
 Loadpoint_AddAnother = "Do you want to add another loadpoint?"
 Loadpoint_DefaultTitle = "Garage"
-Loadpoint_ResetOnDisconnect = "Shall disconnecting the charging cable from the vehicle reset the charging settings to the defaults?"
-Loadpoint_WallboxWOMeter = "The system could not detect if the wallbox provides power data. Do you want to add an external meter instead?"
-Loadpoint_WallboxMaxPower = "What is the maximum power your wallbox can provide?"
-Loadpoint_WallboxMinAmperage = "What is the MINIMUM amperage your wallbox can provide on a single phase?"
-Loadpoint_WallboxMaxAmperage = "What is the MAXIMUM amperage your wallbox can provide on a single phase?"
-Loadpoint_WallboxPhases = "How many phases are connected to the wallbox?"
+Loadpoint_ResetOnDisconnect = "Will disconnecting the charging cable from the vehicle reset the charging settings to the defaults?"
+Loadpoint_WallboxWOMeter = "The system could not detect if the charger provides power data. Do you want to add an external meter instead?"
+Loadpoint_WallboxMaxPower = "What is the maximum power your charger can provide?"
+Loadpoint_WallboxMinAmperage = "What is the MINIMUM amperage your charger can provide on a single phase?"
+Loadpoint_WallboxMaxAmperage = "What is the MAXIMUM amperage your charger can provide on a single phase?"
+Loadpoint_WallboxPhases = "How many phases are connected to the charger?"
 Loadpoint_WallboxPower36kW = "3.6 kW"
 Loadpoint_WallboxPower11kW = "11 kW"
 Loadpoint_WallboxPower22kW = "22 kW"
diff --git a/cmd/setup.go b/cmd/setup.go
index 9133ba85..24dc706c 100644
--- a/cmd/setup.go
+++ b/cmd/setup.go
@@ -161,14 +161,21 @@ func configureInflux(conf server.InfluxConfig, site site.API, in <-chan util.Par
 func configureMQTT(conf mqttConfig) error {
 	log := util.NewLogger("mqtt")
 
-	var err error
-	if mqtt.Instance, err = mqtt.RegisteredClient(log, conf.Broker, conf.User, conf.Password, conf.ClientID, 1, conf.Insecure, func(options *paho.ClientOptions) {
+	instance, err := mqtt.RegisteredClient(log, conf.Broker, conf.User, conf.Password, conf.ClientID, 1, conf.Insecure, func(options *paho.ClientOptions) {
 		topic := fmt.Sprintf("%s/status", strings.Trim(conf.Topic, "/"))
 		options.SetWill(topic, "offline", 1, true)
-	}); err != nil {
+
+		oc := options.OnConnect
+		options.SetOnConnectHandler(func(client paho.Client) {
+			oc(client)                                       // original handler
+			_ = mqtt.Instance.Publish(topic, true, "online") // alive
+		})
+	})
+	if err != nil {
 		return fmt.Errorf("failed configuring mqtt: %w", err)
 	}
 
+	mqtt.Instance = instance
 	return nil
 }
 
diff --git a/core/db/session.go b/core/db/session.go
index cbdebacb..b75de6f6 100644
--- a/core/db/session.go
+++ b/core/db/session.go
@@ -20,20 +20,21 @@ import (
 
 // Session is a single charging session
 type Session struct {
-	ID              uint      `json:"id" csv:"-" gorm:"primarykey"`
-	Created         time.Time `json:"created"`
-	Finished        time.Time `json:"finished"`
-	Loadpoint       string    `json:"loadpoint"`
-	Identifier      string    `json:"identifier"`
-	Vehicle         string    `json:"vehicle"`
-	Odometer        *float64  `json:"odometer" format:"int"`
-	MeterStart      *float64  `json:"meterStart" csv:"Meter Start (kWh)" gorm:"column:meter_start_kwh"`
-	MeterStop       *float64  `json:"meterStop" csv:"Meter Stop (kWh)" gorm:"column:meter_end_kwh"`
-	ChargedEnergy   float64   `json:"chargedEnergy" csv:"Charged Energy (kWh)" gorm:"column:charged_kwh"`
-	SolarPercentage *float64  `json:"solarPercentage" csv:"Solar (%)" gorm:"column:solar_percentage"`
-	Price           *float64  `json:"price" csv:"Price" gorm:"column:price"`
-	PricePerKWh     *float64  `json:"pricePerKWh" csv:"Price/kWh" gorm:"column:price_per_kwh"`
-	Co2PerKWh       *float64  `json:"co2PerKWh" csv:"CO2/kWh (gCO2eq)" gorm:"column:co2_per_kwh"`
+	ID              uint           `json:"id" csv:"-" gorm:"primarykey"`
+	Created         time.Time      `json:"created"`
+	Finished        time.Time      `json:"finished"`
+	Loadpoint       string         `json:"loadpoint"`
+	Identifier      string         `json:"identifier"`
+	Vehicle         string         `json:"vehicle"`
+	Odometer        *float64       `json:"odometer" format:"int"`
+	MeterStart      *float64       `json:"meterStart" csv:"Meter Start (kWh)" gorm:"column:meter_start_kwh"`
+	MeterStop       *float64       `json:"meterStop" csv:"Meter Stop (kWh)" gorm:"column:meter_end_kwh"`
+	ChargedEnergy   float64        `json:"chargedEnergy" csv:"Charged Energy (kWh)" gorm:"column:charged_kwh"`
+	ChargeDuration  *time.Duration `json:"chargeDuration" csv:"Charge Duration" gorm:"column:charge_duration"`
+	SolarPercentage *float64       `json:"solarPercentage" csv:"Solar (%)" gorm:"column:solar_percentage"`
+	Price           *float64       `json:"price" csv:"Price" gorm:"column:price"`
+	PricePerKWh     *float64       `json:"pricePerKWh" csv:"Price/kWh" gorm:"column:price_per_kwh"`
+	Co2PerKWh       *float64       `json:"co2PerKWh" csv:"CO2/kWh (gCO2eq)" gorm:"column:co2_per_kwh"`
 }
 
 // Sessions is a list of sessions
@@ -71,6 +72,26 @@ func (t *Sessions) writeHeader(ctx context.Context, ww *csv.Writer) error {
 	return ww.Write(row)
 }
 
+func formatValue(mp *message.Printer, value any, digits int) string {
+	if rv := reflect.ValueOf(value); rv.Kind() == reflect.Pointer && rv.IsNil() {
+		return ""
+	}
+
+	switch v := value.(type) {
+	case float64:
+		return mp.Sprint(number.Decimal(v, number.NoSeparator(), number.MaxFractionDigits(digits)))
+	case *float64:
+		return mp.Sprint(number.Decimal(*v, number.NoSeparator(), number.MaxFractionDigits(digits)))
+	case time.Time:
+		if v.IsZero() {
+			return ""
+		}
+		return v.Local().Format("2006-01-02 15:04:05")
+	default:
+		return fmt.Sprintf("%v", value)
+	}
+}
+
 func (t *Sessions) writeRow(ww *csv.Writer, mp *message.Printer, r Session) error {
 	var row []string
 	for _, f := range structs.Fields(r) {
@@ -83,20 +104,7 @@ func (t *Sessions) writeRow(ww *csv.Writer, mp *message.Printer, r Session) erro
 			digits = 0
 		}
 
-		var val string
-
-		if rv := reflect.ValueOf(f.Value()); !(rv.Kind() == reflect.Pointer && rv.IsNil()) {
-			switch v := f.Value().(type) {
-			case float64, *float64:
-				val = mp.Sprint(number.Decimal(v, number.NoSeparator(), number.MaxFractionDigits(digits)))
-			case time.Time:
-				if !v.IsZero() {
-					val = v.Local().Format("2006-01-02 15:04:05")
-				}
-			default:
-				val = fmt.Sprintf("%v", f.Value())
-			}
-		}
+		val := formatValue(mp, f.Value(), digits)
 
 		row = append(row, val)
 	}
diff --git a/core/loadpoint.go b/core/loadpoint.go
index abbbdd04..7b4e2306 100644
--- a/core/loadpoint.go
+++ b/core/loadpoint.go
@@ -656,11 +656,8 @@ func (lp *Loadpoint) syncCharger() error {
 					lp.log.WARN.Printf("charger logic error: current mismatch (got %.3gA, expected %.3gA)", current, lp.chargeCurrent)
 				}
 
-				if charger, ok := lp.charger.(api.ChargerEx); ok {
-					return charger.MaxCurrentMillis(lp.chargeCurrent)
-				}
-
-				return lp.charger.MaxCurrent(int64(lp.chargeCurrent))
+				lp.chargeCurrent = current
+				lp.bus.Publish(evChargeCurrent, lp.chargeCurrent)
 			}
 		}
 
diff --git a/core/loadpoint_session.go b/core/loadpoint_session.go
index 2064e77c..a652696e 100644
--- a/core/loadpoint_session.go
+++ b/core/loadpoint_session.go
@@ -73,6 +73,7 @@ func (lp *Loadpoint) stopSession() {
 	s.PricePerKWh = lp.sessionEnergy.PricePerKWh()
 	s.Co2PerKWh = lp.sessionEnergy.Co2PerKWh()
 	s.ChargedEnergy = lp.sessionEnergy.TotalWh() / 1e3
+	s.ChargeDuration = &lp.chargeDuration
 
 	lp.db.Persist(s)
 }
diff --git a/i18n/de.toml b/i18n/de.toml
index 87f5d419..78ec62e4 100644
--- a/i18n/de.toml
+++ b/i18n/de.toml
@@ -145,7 +145,7 @@ selfConsumption = "Eigenverbrauch"
 avgPrice = "⌀ Preis"
 charged = "Geladen"
 co2 = "⌀ CO₂"
-duration = "Dauer"
+duration = "Ladedauer"
 fallbackName = "Ladepunkt"
 power = "Leistung"
 price = "Σ Preis"
@@ -198,7 +198,7 @@ descriptionEnergy = "Bis wann sollen {targetEnergy} ins Fahrzeug geladen sein?"
 descriptionSoc = "Wann soll das Fahrzeug auf {targetSoc}% geladen sein?"
 inactiveLabel = "Zielzeit"
 onlyInPvMode = "Ladeplanung ist nur im PV-Modus aktiv."
-planDuration = "Ladezeit"
+planDuration = "Ladedauer"
 planPeriodLabel = "Zeitraum"
 planPeriodValue = "{start} bis {end}"
 planUnknown = "noch unbekannt"
@@ -214,7 +214,7 @@ tomorrow = "morgen"
 update = "Aktualisieren"
 
 [main.targetChargePlan]
-chargeDuration = "Ladezeit"
+chargeDuration = "Ladedauer"
 co2Label = "CO₂-Emission ⌀"
 priceLabel = "Energiepreis"
 timeRange = "{day} {range} Uhr"
@@ -287,7 +287,9 @@ started = "Startzeit"
 title = "Ladevorgang"
 
 [sessions]
+avgPower = "⌀ Leistung"
 avgPrice = "⌀ Preis"
+chargeDuration = "Ladedauer"
 co2 = "⌀ CO₂"
 csvMonth = "Download {month} CSV"
 csvTotal = "Gesamte CSV herunterladen"
diff --git a/i18n/en.toml b/i18n/en.toml
index 1109f6b4..6de40848 100644
--- a/i18n/en.toml
+++ b/i18n/en.toml
@@ -287,7 +287,9 @@ started = "Started"
 title = "Charging Session"
 
 [sessions]
+avgPower = "⌀ Power"
 avgPrice = "⌀ Price"
+chargeDuration = "Duration"
 co2 = "⌀ CO₂"
 csvMonth = "Download {month} CSV"
 csvTotal = "Download total CSV"
diff --git a/i18n/es.toml b/i18n/es.toml
index f7e0ec3f..de24dc33 100644
--- a/i18n/es.toml
+++ b/i18n/es.toml
@@ -239,6 +239,8 @@ fallbackName = "Vehículo"
 moreActions = "Más acciones"
 none = "Ningún vehículo"
 targetSoc = "Objetivo de carga"
+temp = "T.ª"
+tempLimit = "Temperatura límite"
 unknown = "Vehículo invitado"
 vehicleSoc = "Nivel de carga"
 
@@ -294,7 +296,9 @@ started = "Comenzó"
 title = "Cargando la sesión"
 
 [sessions]
+avgPower = "⌀ Potencia"
 avgPrice = "Precio ⌀"
+chargeDuration = "Duración"
 co2 = "CO₂ ⌀"
 csvMonth = "Descargar el {month} CSV"
 csvTotal = "Descargar todos los CSV"
diff --git a/i18n/lt.toml b/i18n/lt.toml
index 464505b2..941e2c45 100644
--- a/i18n/lt.toml
+++ b/i18n/lt.toml
@@ -239,6 +239,8 @@ fallbackName = "Automobilis"
 moreActions = "Daugiau veiksmų"
 none = "Nėra automobilio"
 targetSoc = "Limitas"
+temp = "Temp."
+tempLimit = "Temp. limitas"
 unknown = "Nežinomas automobilis"
 vehicleSoc = "Baterija"
 
@@ -294,7 +296,9 @@ started = "Pradėta"
 title = "Įkrovimo sesija"
 
 [sessions]
+avgPower = "⌀ Galia"
 avgPrice = "⌀ Kaina"
+chargeDuration = "Trukmė"
 co2 = "⌀ CO₂"
 csvMonth = "Atsisiųsti {month} CSV"
 csvTotal = "Atsisiųsti visą CSV"
diff --git a/i18n/pl.toml b/i18n/pl.toml
index 5c1b50bd..f2286596 100644
--- a/i18n/pl.toml
+++ b/i18n/pl.toml
@@ -239,6 +239,8 @@ fallbackName = "Pojazd"
 moreActions = "Więcej akcji"
 none = "Brak pojazdu"
 targetSoc = "Ograniczenie"
+temp = "Temp."
+tempLimit = " Ograniczenie temp."
 unknown = "Pojazd gościa"
 vehicleSoc = "Bateria"
 
@@ -292,7 +294,9 @@ started = "Zaczęło się"
 title = "Sesja ładowania"
 
 [sessions]
+avgPower = "⌀ Moc"
 avgPrice = "⌀ Cena"
+chargeDuration = "Czas trwania"
 co2 = "⌀ CO₂"
 csvMonth = "Pobierz plik {month} CSV"
 csvTotal = "Pobierz plik CSV"
diff --git a/i18n/pt.toml b/i18n/pt.toml
index af21882c..2f5ad416 100644
--- a/i18n/pt.toml
+++ b/i18n/pt.toml
@@ -239,6 +239,8 @@ fallbackName = "Veículo"
 moreActions = "Mais ações"
 none = "Nenhum veículo"
 targetSoc = "Alvo de carga"
+temp = "Temperatura"
+tempLimit = "Temperatura alvo"
 unknown = "Veículo convidado"
 vehicleSoc = "Nível de carga"
 
@@ -294,7 +296,9 @@ started = "Começado"
 title = "Sessão de carga"
 
 [sessions]
+avgPower = "Desempenho ⌀"
 avgPrice = "Preço ⌀"
+chargeDuration = "Duração"
 co2 = "CO₂ ⌀"
 csvMonth = "Download {month} CSV"
 csvTotal = "Download tudo CSV"
diff --git a/i18n/uk.toml b/i18n/uk.toml
index fbc61baf..229b90b5 100644
--- a/i18n/uk.toml
+++ b/i18n/uk.toml
@@ -198,12 +198,14 @@ descriptionEnergy = "До якого часу {targetEnergy} повинен бу
 descriptionSoc = "Коли автомобіль повинен бути заряджений на {targetSoc}%?"
 inactiveLabel = "Запланований час"
 modalTitle = "Встановити запланований час"
+onlyInPvMode = "План заряджання працює тільки в сонячному режимі."
 planDuration = "Час зарядки"
 planPeriodLabel = "Період"
 planPeriodValue = "{start} до {end}"
 planUnknown = "ще не відомо"
 priceLimit = "ліміт ціни {price}"
 setTargetTime = "жодного"
+targetEnergyRequired = "Також потрібно встановити ліміт енергії"
 targetIsInThePast = "Виберіть час у майбутньому."
 targetIsTooFarInTheFuture = "Ми скоригуємо план, як тільки дізнаємося більше про майбутнє."
 title = "Запланований Час"
diff --git a/server/mqtt.go b/server/mqtt.go
index 0a337807..91e9903f 100644
--- a/server/mqtt.go
+++ b/server/mqtt.go
@@ -209,10 +209,6 @@ func (m *MQTT) listenSetters(topic string, site site.API, lp loadpoint.API) {
 
 // Run starts the MQTT publisher for the MQTT API
 func (m *MQTT) Run(site site.API, in <-chan util.Param) {
-	// alive
-	topic := fmt.Sprintf("%s/status", m.root)
-	m.publish(topic, true, "online")
-
 	// site setters
 	m.Handler.ListenSetter(m.root+"/site/prioritySoc", func(payload string) error {
 		val, err := parseFloat(payload)
@@ -255,7 +251,7 @@ func (m *MQTT) Run(site site.API, in <-chan util.Param) {
 	})
 
 	// number of loadpoints
-	topic = fmt.Sprintf("%s/loadpoints", m.root)
+	topic := fmt.Sprintf("%s/loadpoints", m.root)
 	m.publish(topic, true, len(site.Loadpoints()))
 
 	// loadpoint setters
diff --git a/util/tee.go b/util/tee.go
index 69318c71..7bbae57d 100644
--- a/util/tee.go
+++ b/util/tee.go
@@ -1,14 +1,18 @@
 package util
 
-import "reflect"
+import (
+	"reflect"
+	"sync"
+)
 
-// TeeAttacher allows to attach a listener to a tee
+// TeeAttacher allows attaching a listener to a tee
 type TeeAttacher interface {
 	Attach() <-chan Param
 }
 
-// Tee distributed parameters to subscribers
+// Tee distributes parameters to subscribers
 type Tee struct {
+	mu   sync.Mutex
 	recv []chan<- Param
 }
 
@@ -23,21 +27,24 @@ func (t *Tee) Attach() <-chan Param {
 
 // add attaches a receiver channel to the tee
 func (t *Tee) add(out chan<- Param) {
+	t.mu.Lock()
+	defer t.mu.Unlock()
 	t.recv = append(t.recv, out)
 }
 
 // Run starts parameter distribution
 func (t *Tee) Run(in <-chan Param) {
 	for msg := range in {
-		for _, recv := range t.recv {
-			// dereference pointers (https://github.com/evcc-io/evcc/issues/7895)
-			if val := reflect.ValueOf(msg.Val); val.Kind() == reflect.Ptr {
-				if ptr := reflect.Indirect(val); ptr.IsValid() {
-					msg.Val = ptr.Addr().Elem().Interface()
-				}
+		if val := reflect.ValueOf(msg.Val); val.Kind() == reflect.Ptr {
+			if ptr := reflect.Indirect(val); ptr.IsValid() {
+				msg.Val = ptr.Addr().Elem().Interface()
 			}
+		}
 
+		t.mu.Lock()
+		for _, recv := range t.recv {
 			recv <- msg
 		}
+		t.mu.Unlock()
 	}
 }


Test patch:
diff --git a/assets/js/components/VehicleStatus.test.js b/assets/js/components/VehicleStatus.test.js
index 69fe54d7..7612c3b9 100644
--- a/assets/js/components/VehicleStatus.test.js
+++ b/assets/js/components/VehicleStatus.test.js
@@ -82,7 +82,7 @@ describe("timer", () => {
         pvRemainingInterpolated: 90,
       },
       "pvEnable",
-      { remaining: "1:30m" }
+      { remaining: "1:30\u202Fm" }
     );
   });
   test("don't show pv enable timer if value is zero", () => {
@@ -104,7 +104,7 @@ describe("timer", () => {
         pvRemainingInterpolated: 90,
       },
       "pvDisable",
-      { remaining: "1:30m" }
+      { remaining: "1:30\u202Fm" }
     );
   });
   test("show phase enable timer if it exists", () => {
@@ -116,7 +116,7 @@ describe("timer", () => {
         phaseRemainingInterpolated: 90,
       },
       "scale1p",
-      { remaining: "1:30m" }
+      { remaining: "1:30\u202Fm" }
     );
   });
   test("show phase disable timer if it exists", () => {
@@ -128,7 +128,7 @@ describe("timer", () => {
         phaseRemainingInterpolated: 90,
       },
       "scale3p",
-      { remaining: "1:30m" }
+      { remaining: "1:30\u202Fm" }
     );
   });
   test("show guard timer if it exists", () => {
@@ -139,7 +139,7 @@ describe("timer", () => {
         guardRemainingInterpolated: 90,
       },
       "guard",
-      { remaining: "1:30m" }
+      { remaining: "1:30\u202Fm" }
     );
   });
   test("don't show guard timer if another timer exists", () => {
@@ -153,7 +153,7 @@ describe("timer", () => {
         guardRemainingInterpolated: 90,
       },
       "pvDisable",
-      { remaining: "30s" }
+      { remaining: "30\u202Fs" }
     );
   });
   test("show guard timer if charging and no other timer exists", () => {
@@ -165,7 +165,7 @@ describe("timer", () => {
         guardRemainingInterpolated: 90,
       },
       "guard",
-      { remaining: "1:30m" }
+      { remaining: "1:30\u202Fm" }
     );
   });
 });
diff --git a/assets/js/mixins/formatter.test.js b/assets/js/mixins/formatter.test.js
index c34863ee..8ebfefe8 100644
--- a/assets/js/mixins/formatter.test.js
+++ b/assets/js/mixins/formatter.test.js
@@ -68,3 +68,35 @@ describe("pricePerKWhUnit", () => {
     expect(fmt.pricePerKWhUnit("CHF")).eq("rp/kWh");
   });
 });
+
+describe("fmtDuration", () => {
+  test("should format zero duration", () => {
+    expect(fmt.fmtDuration(0)).eq("—");
+    expect(fmt.fmtDuration(-100)).eq("—");
+  });
+  test("should format seconds", () => {
+    expect(fmt.fmtDuration(1)).eq("1\u202Fs");
+    expect(fmt.fmtDuration(59)).eq("59\u202Fs");
+    expect(fmt.fmtDuration(59, false)).eq("59");
+    expect(fmt.fmtDuration(59, true, "m")).eq("0:59\u202Fm");
+    expect(fmt.fmtDuration(59, true, "h")).eq("0:00\u202Fh");
+  });
+  test("should format minutes", () => {
+    expect(fmt.fmtDuration(60)).eq("1:00\u202Fm");
+    expect(fmt.fmtDuration(150)).eq("2:30\u202Fm");
+    expect(fmt.fmtDuration(150, false)).eq("2:30");
+    expect(fmt.fmtDuration(150, true, "h")).eq("0:02\u202Fh");
+  });
+  test("should format hours", () => {
+    expect(fmt.fmtDuration(60 * 60)).eq("1:00\u202Fh");
+    expect(fmt.fmtDuration(60 * 60 * 2.5)).eq("2:30\u202Fh");
+    expect(fmt.fmtDuration(60 * 60 * 2.5, false)).eq("2:30");
+  });
+  test("should format internationalized", () => {
+    config.global.mocks["$i18n"].locale = "ar-EG";
+    expect(fmt.fmtDuration(30, false)).eq("٣٠");
+    expect(fmt.fmtDuration(90, false)).eq("١:٣٠");
+    expect(fmt.fmtDuration(60 * 100, false)).eq("١:٤٠");
+    config.global.mocks["$i18n"].locale = "de-DE";
+  });
+});
diff --git a/core/db/format_test.go b/core/db/format_test.go
new file mode 100644
index 00000000..21ea67fc
--- /dev/null
+++ b/core/db/format_test.go
@@ -0,0 +1,17 @@
+package db
+
+import (
+	"testing"
+
+	"github.com/stretchr/testify/assert"
+	"golang.org/x/text/language"
+	"golang.org/x/text/message"
+)
+
+func TestFormatValue(t *testing.T) {
+	mp := message.NewPrinter(language.Make("en"))
+
+	f := 1.2345
+	assert.Equal(t, "1.234", formatValue(mp, f, 3))
+	assert.Equal(t, "1.234", formatValue(mp, &f, 3))
+}
diff --git a/tests/sessions.spec.js b/tests/sessions.spec.js
index dc0a0dc8..28e7b9dc 100644
--- a/tests/sessions.spec.js
+++ b/tests/sessions.spec.js
@@ -33,6 +33,7 @@ test.describe("basics", async () => {
     await expect(page.getByTestId("sessions-nodata")).toHaveCount(0);
     await expect(page.getByRole("table")).toBeVisible();
     await expect(page.getByTestId("sessions-head")).toHaveCount(1);
+    await expect(page.getByTestId("sessions-head").locator("th")).toHaveCount(9);
 
     await expect(page.getByTestId("sessions-head-energy")).toContainText("ChargedkWh");
     await expect(page.getByTestId("sessions-foot-energy")).toBeVisible("20.0");
@@ -46,6 +47,16 @@ test.describe("basics", async () => {
     await expect(page.getByTestId("sessions-head-avgPrice")).toContainText("⌀ Pricect/kWh");
     await expect(page.getByTestId("sessions-foot-avgPrice")).toBeVisible("27.5");
 
+    await expect(page.getByTestId("sessions-head-chargeDuration")).toContainText("Durationh:mm");
+    await expect(page.getByTestId("sessions-foot-chargeDuration")).toBeVisible("1:30");
+
+    await page
+      .getByTestId("sessions-head-chargeDuration")
+      .getByRole("combobox")
+      .selectOption("⌀ Power");
+    await expect(page.getByTestId("sessions-head-avgPower")).toContainText("⌀ PowerkW");
+    await expect(page.getByTestId("sessions-foot-avgPower")).toBeVisible("1:30");
+
     await expect(page.getByTestId("sessions-entry")).toHaveCount(4);
   });
 });
@@ -55,40 +66,32 @@ test.describe("mobile basics", async () => {
     await page.setViewportSize(mobile);
     await page.goto("/#/sessions?year=2023&month=5");
 
-    // hidden columns
-    await expect(page.getByTestId("sessions-head-energy")).not.toBeVisible();
-    await expect(page.getByTestId("sessions-foot-energy")).not.toBeVisible();
-    await expect(page.getByTestId("sessions-head-solar")).not.toBeVisible();
-    await expect(page.getByTestId("sessions-foot-solar")).not.toBeVisible();
-    await expect(page.getByTestId("sessions-head-price")).not.toBeVisible();
-    await expect(page.getByTestId("sessions-foot-price")).not.toBeVisible();
-    await expect(page.getByTestId("sessions-head-avgPrice")).not.toBeVisible();
-    await expect(page.getByTestId("sessions-foot-avgPrice")).not.toBeVisible();
-
-    await expect(page.getByTestId("sessions-head-mobile")).toContainText("ChargedkWh");
-    await expect(page.getByTestId("sessions-foot-mobile")).toBeVisible("20.0");
-
-    await page.getByTestId("mobile-column").selectOption("Solar");
-    await expect(page.getByTestId("sessions-head-mobile")).toContainText("Solar%");
-    await expect(page.getByTestId("sessions-foot-mobile")).toBeVisible("67.3");
-
-    await page.getByTestId("mobile-column").selectOption("Σ Price");
-    await expect(page.getByTestId("sessions-head-mobile")).toContainText("Σ Price€");
-    await expect(page.getByTestId("sessions-foot-mobile")).toBeVisible("5.50");
-
-    await page.getByTestId("mobile-column").selectOption("⌀ Price");
-    await expect(page.getByTestId("sessions-head-mobile")).toContainText("⌀ Pricect/kWh");
-    await expect(page.getByTestId("sessions-foot-mobile")).toBeVisible("27.5");
+    await expect(page.getByTestId("sessions-head").locator("th")).toHaveCount(5);
+
+    await expect(page.getByTestId("sessions-head-energy")).toContainText("ChargedkWh");
+    await expect(page.getByTestId("sessions-foot-energy")).toBeVisible("20.0");
+
+    await page.getByTestId("sessions-head-energy").getByRole("combobox").selectOption("Solar");
+    await expect(page.getByTestId("sessions-head-solar")).toContainText("Solar%");
+    await expect(page.getByTestId("sessions-foot-solar")).toBeVisible("67.3");
+
+    await page.getByTestId("sessions-head-solar").getByRole("combobox").selectOption("Σ Price");
+    await expect(page.getByTestId("sessions-head-price")).toContainText("Σ Price€");
+    await expect(page.getByTestId("sessions-foot-price")).toBeVisible("5.50");
+
+    await page.getByTestId("sessions-head-price").getByRole("combobox").selectOption("⌀ Price");
+    await expect(page.getByTestId("sessions-head-avgPrice")).toContainText("⌀ Pricect/kWh");
+    await expect(page.getByTestId("sessions-foot-avgPrice")).toBeVisible("27.5");
   });
 
   test("keep selection when paging", async ({ page }) => {
     await page.setViewportSize(mobile);
     await page.goto("/#/sessions?year=2023&month=5");
 
-    await page.getByTestId("mobile-column").selectOption("Solar");
+    await page.getByTestId("sessions-head-energy").getByRole("combobox").selectOption("Solar");
     await page.getByRole("link", { name: "Apr" }).click();
     await page.getByRole("link", { name: "May" }).click();
-    await expect(page.getByTestId("sessions-head-mobile")).toContainText("Solar%");
+    await expect(page.getByTestId("sessions-head-solar")).toContainText("Solar%");
   });
 });
 
diff --git a/tests/sessions.sql b/tests/sessions.sql
index 89f13903..bc2bbf16 100644
--- a/tests/sessions.sql
+++ b/tests/sessions.sql
@@ -14,11 +14,12 @@ CREATE TABLE `sessions` (
   `price` real, 
   `price_per_kwh` real,
   `co2_per_kwh` real,
+  `charge_duration` integer,
   PRIMARY KEY (`id`)
 );
 
-INSERT INTO `sessions` VALUES (1,'2023-03-01 08:00:00.0+02:00','2023-05-02 12:00:00.0+02:00','Carport',NULL,'blauer e-Golf',NULL,NULL,10,NULL,100,2,0.2,300);
-INSERT INTO `sessions` VALUES (2,'2023-05-02 08:00:00.0+02:00','2023-05-02 12:00:00.0+02:00','Carport',NULL,'blauer e-Golf',NULL,NULL,10,NULL,100,2,0.2,NULL);
-INSERT INTO `sessions` VALUES (3,'2023-05-02 08:00:00.0+02:00','2023-05-02 12:00:00.0+02:00','Carport',NULL,'blauer e-Golf',NULL,NULL,2.5,NULL,88.21,0.75,0.3,NULL);
-INSERT INTO `sessions` VALUES (4,'2023-05-03 16:00:00.0+02:00','2023-05-03 20:00:00.0+02:00','Carport',NULL,'weißes Model 3',NULL,NULL,2.5,NULL,50,0.25,0.1,NULL);
-INSERT INTO `sessions` VALUES (5,'2023-05-04 22:00:00.0+02:00','2023-05-05 06:00:00.0+02:00','Garage',NULL,'weißes Model 3',NULL,NULL,5,NULL,0,2.5,0.5,NULL);
\ No newline at end of file
+INSERT INTO `sessions` VALUES (1,'2023-03-01 08:00:00.0+02:00','2023-05-02 12:00:00.0+02:00','Carport',NULL,'blauer e-Golf',NULL,NULL,10,NULL,100,2,0.2,300,3600000000000);
+INSERT INTO `sessions` VALUES (2,'2023-05-02 08:00:00.0+02:00','2023-05-02 12:00:00.0+02:00','Carport',NULL,'blauer e-Golf',NULL,NULL,10,NULL,100,2,0.2,NULL,1800000000000);
+INSERT INTO `sessions` VALUES (3,'2023-05-02 08:00:00.0+02:00','2023-05-02 12:00:00.0+02:00','Carport',NULL,'blauer e-Golf',NULL,NULL,2.5,NULL,88.21,0.75,0.3,NULL,NULL);
+INSERT INTO `sessions` VALUES (4,'2023-05-03 16:00:00.0+02:00','2023-05-03 20:00:00.0+02:00','Carport',NULL,'weißes Model 3',NULL,NULL,2.5,NULL,50,0.25,0.1,NULL,NULL);
+INSERT INTO `sessions` VALUES (5,'2023-05-04 22:00:00.0+02:00','2023-05-05 06:00:00.0+02:00','Garage',NULL,'weißes Model 3',NULL,NULL,5,NULL,0,2.5,0.5,null,3600000000000);
\ No newline at end of file