Repository: evcc-io/evcc
Pull request #8403 (2023-06-11 09:32:15)

FoxESS H3: add grid
Es gab auch Meldungen, der H3 könne nur rs485. Im Moment haben die Templates einen komischen Zustand- es gibt h1, h3 und rs485. Wirkt inkonsistent, ich wüsste aber nicht, wie wir das sortieren sollten?


Patch:
diff --git a/.github/workflows/default.yml b/.github/workflows/default.yml
index 5b6c099d..00e9358b 100644
--- a/.github/workflows/default.yml
+++ b/.github/workflows/default.yml
@@ -44,15 +44,38 @@ jobs:
     steps:
       - uses: actions/checkout@v3
 
-      # - uses: actions/setup-go@v4
       - uses: erezrokah/setup-go@feat/add_cache_prefix
         with:
           go-version: ^1.20
           cache-key-prefix: build-cache-
         id: go
 
+      - uses: actions/setup-node@v3
+        with:
+          node-version: "18"
+          cache: "npm"
+
+      - name: Install UI
+        run: make install-ui
+
+      - name: Build UI
+        run: make ui
+
       - name: Build
-        run: mkdir dist && touch dist/empty && make build
+        run: make build
+
+      - name: Cache dist
+        uses: actions/cache/save@v3
+        id: cache-dist
+        with:
+          path: dist
+          key: ${{ runner.os }}-${{ github.sha }}-dist
+
+      - name: Upload
+        uses: actions/upload-artifact@v3
+        with:
+          name: evcc-binary
+          path: ./evcc
 
   test:
     name: Test
@@ -92,7 +115,7 @@ jobs:
           version: latest
           args: --timeout 5m
 
-  build-ui:
+  ui:
     name: UI
     runs-on: ubuntu-latest
 
@@ -104,7 +127,7 @@ jobs:
           node-version: "18"
           cache: "npm"
 
-      - name: Install tools
+      - name: Install
         run: make install-ui
 
       - name: Lint
@@ -113,12 +136,49 @@ jobs:
       - name: Test
         run: make test-ui
 
-      - name: Build
-        run: make ui
+      - name: Porcelain
+        run: |
+          test -z "$(git status --porcelain)" || (git status; git diff; false)
 
-      - name: Cache dist
-        uses: actions/cache/save@v3
-        id: cache-dist
+  integration:
+    name: Integration
+    runs-on: ubuntu-latest
+    if: ${{ github.event_name == 'push' || github.event_name == 'pull_request' }}
+
+    steps:
+      - uses: actions/checkout@v3
+
+      - uses: actions/setup-node@v3
         with:
-          path: dist
-          key: ${{ runner.os }}-${{ github.sha }}-dist
+          node-version: "18"
+          cache: "npm"
+
+      - name: Install npm
+        run: npm ci
+
+      - run: npx playwright install --with-deps chromium
+
+      - name: Wait for Build
+        uses: lewagon/wait-on-check-action@v1.3.1
+        with:
+          ref: ${{ github.event.pull_request.head.sha || github.sha }}
+          check-name: Build
+          repo-token: ${{ secrets.GITHUB_TOKEN }}
+          wait-interval: 10
+
+      - uses: actions/download-artifact@v3
+        with:
+          name: evcc-binary
+
+      - name: Make executable
+        run: chmod +x evcc
+
+      - name: Run tests
+        run: npx playwright test
+
+      - uses: actions/upload-artifact@v3
+        if: always()
+        with:
+          name: playwright-report
+          path: playwright-report/
+          retention-days: 30
diff --git a/.gitignore b/.gitignore
index 9da0cd4c..5d5fc691 100644
--- a/.gitignore
+++ b/.gitignore
@@ -12,6 +12,7 @@ __debug_bin
 !util/templates/**/*.yaml
 !package*.json
 !evcc.dist.yaml
+!tests/**/*.evcc.yaml
 evcc
 evcc.exe
 evcc_*
@@ -32,3 +33,6 @@ fly.toml
 vendor/*
 QEMU_EFI.fd
 asset-stats.html
+/test-results/
+/playwright-report/
+/playwright/.cache/
diff --git a/README.md b/README.md
index 9b9ba266..6086dfd9 100644
--- a/README.md
+++ b/README.md
@@ -54,6 +54,8 @@ make
 ./evcc
 ```
 
+### UI development
+
 For frontend development start the Vue toolchain in dev-mode. Open http://127.0.0.1:7071/ to get to the livelreloading development server. It pulls its data from port 7070 (see above).
 
 ```sh
@@ -61,6 +63,15 @@ npm install
 npm run start
 ```
 
+### Integration tests
+
+We use Playwright for end-to-end integration tests. They start a local evcc instance with different configuration yamls and prefilled databases. To run them, you have to do a local build first.
+
+```sh
+make ui build
+npm run playwright
+```
+
 ### Code formatting
 
 We use linters (golangci-lint, Prettier) to keep a coherent source code formatting. It's recommended to use the format-on-save feature of your editor. For VSCode use the [Go](https://marketplace.visualstudio.com/items?itemName=golang.Go), [Prettier](https://marketplace.visualstudio.com/items?itemName=esbenp.prettier-vscode) and [Veture](https://marketplace.visualstudio.com/items?itemName=octref.vetur) extension. You can manually reformat your code by running:
diff --git a/api/api.go b/api/api.go
index bf0d1143..49a42a74 100644
--- a/api/api.go
+++ b/api/api.go
@@ -54,6 +54,15 @@ func ChargeStatusString(s string) (ChargeStatus, error) {
 	}
 }
 
+// ChargeStatusStringWithMapping converts a string to ChargeStatus. In case of error, mapping is applied.
+func ChargeStatusStringWithMapping(s string, m map[ChargeStatus]ChargeStatus) (ChargeStatus, error) {
+	status, err := ChargeStatusString(s)
+	if mappedStatus, ok := m[status]; ok && err != nil {
+		return mappedStatus, nil
+	}
+	return status, err
+}
+
 // String implements Stringer
 func (c ChargeStatus) String() string {
 	return string(c)
diff --git a/assets/js/components/ChargingSessionModal.vue b/assets/js/components/ChargingSessionModal.vue
index 9b6215d7..e2e9d96c 100644
--- a/assets/js/components/ChargingSessionModal.vue
+++ b/assets/js/components/ChargingSessionModal.vue
@@ -67,7 +67,12 @@
 										{{ $t("sessions.energy") }}
 									</th>
 									<td>
-										{{ fmtKWh(session.chargedEnergy * 1e3) }}
+										{{
+											fmtKWh(
+												session.chargedEnergy * 1e3,
+												session.chargedEnergy >= 1
+											)
+										}}
 									</td>
 								</tr>
 								<tr v-if="session.solarPercentage != null">
diff --git a/assets/js/components/Energyflow/Visualization.vue b/assets/js/components/Energyflow/Visualization.vue
index 57745dd6..53aa89d1 100644
--- a/assets/js/components/Energyflow/Visualization.vue
+++ b/assets/js/components/Energyflow/Visualization.vue
@@ -1,5 +1,9 @@
 <template>
-	<div class="visualization" :class="{ 'visualization--ready': visualizationReady }">
+	<div
+		data-testid="visualization"
+		class="visualization"
+		:class="{ 'visualization--ready': visualizationReady }"
+	>
 		<div class="label-scale d-flex">
 			<div class="d-flex justify-content-start flex-grow-1">
 				<LabelBar v-bind="labelBarProps('top', 'pvProduction')">
diff --git a/assets/js/components/Loadpoints.vue b/assets/js/components/Loadpoints.vue
index dbc0fc07..0a03afb2 100644
--- a/assets/js/components/Loadpoints.vue
+++ b/assets/js/components/Loadpoints.vue
@@ -11,6 +11,7 @@
 				<Loadpoint
 					v-bind="loadpoint"
 					:id="index + 1"
+					data-testid="loadpoint"
 					:vehicles="vehicles"
 					:smartCostLimit="smartCostLimit"
 					:smartCostType="smartCostType"
diff --git a/assets/js/components/TopNavigation.vue b/assets/js/components/TopNavigation.vue
index be1b8ceb..53e6aa8e 100644
--- a/assets/js/components/TopNavigation.vue
+++ b/assets/js/components/TopNavigation.vue
@@ -6,6 +6,7 @@
 			data-bs-toggle="dropdown"
 			aria-expanded="false"
 			class="btn btn-sm btn-outline-secondary position-relative border-0 menu-button"
+			data-testid="topnavigation-button"
 		>
 			<span
 				v-if="showBadge"
@@ -15,7 +16,11 @@
 			</span>
 			<shopicon-regular-menu></shopicon-regular-menu>
 		</button>
-		<ul class="dropdown-menu dropdown-menu-end" aria-labelledby="topNavigatonDropdown">
+		<ul
+			class="dropdown-menu dropdown-menu-end"
+			aria-labelledby="topNavigatonDropdown"
+			data-testid="topnavigation-dropdown"
+		>
 			<li>
 				<router-link class="dropdown-item" to="/sessions">
 					{{ $t("header.sessions") }}
diff --git a/assets/js/mixins/formatter.js b/assets/js/mixins/formatter.js
index 4b7a5a3a..0d6133d1 100644
--- a/assets/js/mixins/formatter.js
+++ b/assets/js/mixins/formatter.js
@@ -170,6 +170,11 @@ export default {
         year: "numeric",
       }).format(date);
     },
+    fmtMonth: function (date, short) {
+      return new Intl.DateTimeFormat(this.$i18n.locale, {
+        month: short ? "short" : "long",
+      }).format(date);
+    },
     fmtDayMonthYear: function (date) {
       return new Intl.DateTimeFormat(this.$i18n.locale, {
         day: "numeric",
diff --git a/assets/js/router.js b/assets/js/router.js
index cc1316a2..03518550 100644
--- a/assets/js/router.js
+++ b/assets/js/router.js
@@ -9,7 +9,19 @@ export default function setupRouter(i18n) {
     history: createWebHashHistory(),
     routes: [
       { path: "/", component: Main, props: true },
-      { path: "/sessions", component: ChargingSessions, props: true },
+      {
+        path: "/sessions",
+        component: ChargingSessions,
+        props: (route) => {
+          const { month, year, loadpoint, vehicle } = route.query;
+          return {
+            month: month ? parseInt(month, 10) : undefined,
+            year: year ? parseInt(year, 10) : undefined,
+            loadpointFilter: loadpoint,
+            vehicleFilter: vehicle,
+          };
+        },
+      },
     ],
   });
   router.beforeEach(async () => {
diff --git a/assets/js/views/ChargingSessions.vue b/assets/js/views/ChargingSessions.vue
index 227c763d..2d8de655 100644
--- a/assets/js/views/ChargingSessions.vue
+++ b/assets/js/views/ChargingSessions.vue
@@ -12,121 +12,215 @@
 
 		<div class="row">
 			<main class="col-12">
-				<div class="d-flex justify-content-start mb-4">
-					<a
-						class="btn btn-outline-secondary text-nowrap my-2"
-						:href="csvHrefLink()"
-						download="sessions.csv"
+				<div class="d-flex align-items-baseline justify-content-between my-5">
+					<router-link
+						class="d-flex text-decoration-none align-items-center"
+						:class="{ 'pe-none': !hasPrev, 'text-muted': !hasPrev }"
+						:to="{ query: { ...$route.query, ...prevYearMonth } }"
 					>
-						{{ $t("sessions.downloadCsv") }}
-					</a>
+						<shopicon-regular-angledoubleleftsmall
+							size="s"
+							class="me-1"
+						></shopicon-regular-angledoubleleftsmall>
+						<span class="d-none d-sm-block">{{ prevMonthName }}</span>
+						<span class="d-block d-sm-none">{{ prevMonthNameShort }}</span>
+					</router-link>
+					<h2 class="text-center">{{ headline }}</h2>
+					<router-link
+						class="d-flex text-decoration-none align-items-center"
+						:class="{ 'pe-none': !hasNext, 'text-muted': !hasNext }"
+						:to="{ query: { ...$route.query, ...nextYearMonth } }"
+					>
+						<span class="d-none d-sm-block">{{ nextMonthName }}</span>
+						<span class="d-block d-sm-none">{{ nextMonthNameShort }}</span>
+						<shopicon-regular-angledoublerightsmall
+							size="s"
+							class="ms-1"
+						></shopicon-regular-angledoublerightsmall>
+					</router-link>
 				</div>
-
-				<div v-for="group in sessionsByMonthAndLoadpoint" :key="group.month">
-					<div class="d-flex align-items-center my-5">
-						<h2 class="me-4 mb-0">
-							{{ formatGroupHeadline(group.month) }}
-						</h2>
-						<a
-							class="btn btn-xs btn-outline-secondary text-nowrap"
-							:href="csvHrefLink(group.month)"
-							download="sessions.csv"
-						>
-							CSV
-						</a>
-					</div>
-
-					<div v-for="loadpoint in group.loadpoints" :key="loadpoint.name">
-						<div class="d-flex align-items-baseline mb-3">
-							<h3 class="me-4 mb-0">
-								{{ loadpoint.name }}
-							</h3>
-							<div class="large">{{ fmtKWh(loadpoint.total) }}</div>
-						</div>
-
-						<ul class="breakdown text-gray d-sm-flex flex-sm-wrap ps-0 mb-2">
-							<li
-								v-for="(vehicle, id) in groupedKWh('vehicle', loadpoint.sessions)"
+				<div v-if="currentSessions.length === 0" data-testid="sessions-nodata" class="my-5">
+					<p>{{ $t("sessions.noData") }}</p>
+				</div>
+				<div v-else class="table-responsive my-3">
+					<table class="table text-nowrap">
+						<thead>
+							<tr data-testid="sessions-head">
+								<th scope="col" class="align-top ps-0">
+									{{ $t("sessions.date") }}
+								</th>
+								<th scope="col" class="align-top">
+									{{ $t("sessions.loadpoint") }}
+									<label class="position-relative d-block">
+										<select
+											:value="loadpointFilter"
+											class="custom-select"
+											@change="changeLoadpointFilter"
+										>
+											<option
+												v-for="{
+													name,
+													value,
+													count,
+												} in loadpointFilterOptions"
+												:key="value"
+												:value="value"
+												:disabled="count === 0"
+											>
+												{{ name }} ({{ count }})
+											</option>
+										</select>
+										<span
+											class="fw-normal text-decoration-underline text-nowrap text-muted pe-none"
+										>
+											{{ loadpointFilter || $t("sessions.filter.filter") }}
+										</span>
+									</label>
+								</th>
+								<th scope="col" class="align-top">
+									{{ $t("sessions.vehicle") }}
+									<label class="position-relative d-block">
+										<select
+											:value="vehicleFilter"
+											class="custom-select"
+											@change="changeVehicleFilter"
+										>
+											<option
+												v-for="{
+													name,
+													value,
+													count,
+												} in vehicleFilterOptions"
+												:key="value"
+												:value="value"
+												:disabled="count === 0"
+											>
+												{{ name }} ({{ count }})
+											</option>
+										</select>
+										<span
+											class="fw-normal text-decoration-underline text-nowrap text-muted pe-none"
+										>
+											{{ vehicleFilter || $t("sessions.filter.filter") }}
+										</span>
+									</label>
+								</th>
+								<th scope="col" class="align-top text-end">
+									{{ $t("sessions.energy") }}
+									<div class="text-muted fw-normal">
+										{{ fmtKWh(chargedEnergy * 1e3, chargedEnergy >= 1) }}
+									</div>
+								</th>
+								<th
+									v-if="hasSolarPercentage"
+									scope="col"
+									class="align-top text-end"
+								>
+									{{ $t("sessions.solar") }}
+									<div
+										v-if="solarPercentage != null"
+										class="text-muted fw-normal"
+									>
+										{{ fmtNumber(solarPercentage, 1) }}%
+									</div>
+								</th>
+								<th v-if="hasPrice" scope="col" class="align-top text-end">
+									{{ $t("sessions.price") }}
+									<div v-if="price != null" class="text-muted fw-normal">
+										{{ fmtMoney(price, currency) }}
+										{{ fmtCurrencySymbol(currency) }}
+									</div>
+								</th>
+								<th v-if="hasPrice" scope="col" class="align-top text-end">
+									{{ $t("sessions.avgPrice") }}
+									<div v-if="pricePerKWh != null" class="text-muted fw-normal">
+										{{ fmtPricePerKWh(pricePerKWh, currency) }}
+									</div>
+								</th>
+								<th v-if="hasCo2" scope="col" class="align-top text-end pe-0">
+									{{ $t("sessions.co2") }}
+									<div v-if="co2PerKWh != null" class="text-muted fw-normal">
+										{{ fmtCo2Medium(co2PerKWh) }}
+									</div>
+								</th>
+							</tr>
+						</thead>
+						<tbody>
+							<tr
+								v-for="(session, id) in filteredSessions"
 								:key="id"
-								class="breakdown-item"
+								role="button"
+								data-testid="sessions-entry"
+								@click="showDetails(session.id)"
 							>
-								{{ vehicle.name }}: {{ fmtKWh(vehicle.energy) }}
-							</li>
-						</ul>
-						<div class="table-responsive my-3">
-							<table class="table text-nowrap">
-								<thead>
-									<tr>
-										<th scope="col" class="ps-0">{{ $t("sessions.date") }}</th>
-										<th scope="col">{{ $t("sessions.vehicle") }}</th>
-										<th scope="col" class="text-end">
-											{{ $t("sessions.energy") }}
-										</th>
-										<th scope="col" class="text-end">
-											{{ $t("sessions.solar") }}
-										</th>
-										<th scope="col" class="text-end">
-											{{ $t("sessions.price") }}
-										</th>
-										<th scope="col" class="text-end">
-											{{ $t("sessions.avgPrice") }}
-										</th>
-										<th scope="col" class="text-end pe-0">
-											{{ $t("sessions.co2") }}
-										</th>
-									</tr>
-								</thead>
-								<tbody>
-									<tr
-										v-for="(session, id) in loadpoint.sessions"
-										:key="id"
-										role="button"
-										@click="showDetails(session.id)"
-									>
-										<td class="ps-0">
-											{{ fmtFullDateTime(new Date(session.created), true) }}
-										</td>
-										<td>
-											{{ session.vehicle }}
-										</td>
-										<td class="text-end">
-											{{ fmtKWh(session.chargedEnergy * 1e3) }}
-										</td>
-										<td class="text-end">
-											<span v-if="session.solarPercentage != null">
-												{{ fmtNumber(session.solarPercentage, 1) }}%
-											</span>
-											<span v-else class="text-muted">-</span>
-										</td>
-										<td class="text-end">
-											<span v-if="session.price != null">
-												{{ fmtMoney(session.price, currency) }}
-												{{ fmtCurrencySymbol(currency) }}
-											</span>
-											<span v-else class="text-muted">-</span>
-										</td>
-										<td class="text-end">
-											<span v-if="session.pricePerKWh != null">
-												{{ fmtPricePerKWh(session.pricePerKWh, currency) }}
-											</span>
-											<span v-else class="text-muted">-</span>
-										</td>
-										<td class="text-end pe-0">
-											<span v-if="session.co2PerKWh != null">
-												{{ fmtCo2Medium(session.co2PerKWh) }}
-											</span>
-											<span v-else class="text-muted">-</span>
-										</td>
-									</tr>
-								</tbody>
-							</table>
-						</div>
-					</div>
+								<td class="ps-0">
+									{{ fmtFullDateTime(new Date(session.created), true) }}
+								</td>
+								<td>
+									{{ session.loadpoint }}
+								</td>
+								<td>
+									{{ session.vehicle }}
+								</td>
+								<td class="text-end">
+									{{
+										fmtKWh(
+											session.chargedEnergy * 1e3,
+											session.chargedEnergy >= 1
+										)
+									}}
+								</td>
+								<td v-if="hasSolarPercentage" class="text-end">
+									<span v-if="session.solarPercentage != null">
+										{{ fmtNumber(session.solarPercentage, 1) }}%
+									</span>
+									<span v-else class="text-muted">-</span>
+								</td>
+								<td v-if="hasPrice" class="text-end">
+									<span v-if="session.price != null">
+										{{ fmtMoney(session.price, currency) }}
+										{{ fmtCurrencySymbol(currency) }}
+									</span>
+									<span v-else class="text-muted">-</span>
+								</td>
+								<td v-if="hasPrice" class="text-end">
+									<span v-if="session.pricePerKWh != null">
+										{{ fmtPricePerKWh(session.pricePerKWh, currency) }}
+									</span>
+									<span v-else class="text-muted">-</span>
+								</td>
+								<td v-if="hasCo2" class="text-end pe-0">
+									<span v-if="session.co2PerKWh != null">
+										{{ fmtCo2Medium(session.co2PerKWh) }}
+									</span>
+									<span v-else class="text-muted">-</span>
+								</td>
+							</tr>
+						</tbody>
+					</table>
+				</div>
+				<div class="d-flex mb-5 my-4">
+					<a
+						v-if="currentSessions.length"
+						class="btn btn-outline-secondary text-nowrap me-3"
+						:href="csvLink"
+						download
+					>
+						{{ $t("sessions.csvMonth", { month: headline }) }}
+					</a>
+					<a
+						v-if="sessions.length"
+						class="btn btn-outline-secondary text-nowrap"
+						:href="csvTotalLink"
+						download
+					>
+						{{ $t("sessions.csvTotal") }}
+					</a>
 				</div>
 			</main>
 			<ChargingSessionModal
 				:session="selectedSession"
-				:vehicles="vehicles"
+				:vehicles="vehiclesObjects"
 				@session-changed="loadSessions"
 			/>
 		</div>
@@ -137,6 +231,8 @@
 import Modal from "bootstrap/js/dist/modal";
 import TopNavigation from "../components/TopNavigation.vue";
 import "@h2d2/shopicons/es/bold/arrowback";
+import "@h2d2/shopicons/es/regular/angledoubleleftsmall";
+import "@h2d2/shopicons/es/regular/angledoublerightsmall";
 import formatter from "../mixins/formatter";
 import api from "../api";
 import store from "../store";
@@ -148,31 +244,100 @@ export default {
 	mixins: [formatter],
 	props: {
 		notifications: Array,
+		month: { type: Number, default: () => new Date().getMonth() + 1 },
+		year: { type: Number, default: () => new Date().getFullYear() },
+		loadpointFilter: { type: String, default: "" },
+		vehicleFilter: { type: String, default: "" },
 	},
 	data() {
-		return { sessions: [], selectedSessionId: undefined };
+		return {
+			sessions: [],
+			selectedSessionId: undefined,
+		};
 	},
 	computed: {
-		sessionsByMonthAndLoadpoint() {
+		currentSessions() {
 			const sessionsWithDefaults = this.sessions.map((session) => {
 				const loadpoint = session.loadpoint || this.$t("main.loadpoint.fallbackName");
 				const vehicle = session.vehicle || this.$t("main.vehicle.unknown");
 				return { ...session, loadpoint, vehicle };
 			});
 
-			const sessionsByMonth = this.groupByMonth(sessionsWithDefaults);
-
-			return Object.entries(sessionsByMonth).map(([month, sessions]) => {
-				const loadpoints = Object.entries(this.groupByLoadpoint(sessions)).map(
-					([loadpoint, sessionsByLoadpoint]) => {
-						const total = this.totalKWh(sessionsByLoadpoint);
-						return { name: loadpoint, total, sessions: sessionsByLoadpoint };
-					}
-				);
-				return { month, loadpoints };
+			return sessionsWithDefaults.filter((session) => {
+				const date = new Date(session.created);
+				return date.getFullYear() === this.year && date.getMonth() + 1 === this.month;
 			});
 		},
+		filteredSessions() {
+			return this.currentSessions.filter(this.filterByLoadpoint).filter(this.filterByVehicle);
+		},
+		vehicleFilterOptions() {
+			const options = [
+				{
+					name: this.$t("sessions.filter.allVehicles"),
+					value: "",
+					count: this.filterCountForVehicle(),
+				},
+			];
+			this.vehicles.forEach((name) => {
+				const count = this.filterCountForVehicle(name);
+				options.push({ name, value: name, count });
+			});
+			return options;
+		},
+		loadpointFilterOptions() {
+			const options = [
+				{
+					name: this.$t("sessions.filter.allLoadpoints"),
+					value: "",
+					count: this.filterCountForLoadpoint(),
+				},
+			];
+			this.loadpoints.forEach((name) => {
+				const count = this.filterCountForLoadpoint(name);
+				options.push({ name, value: name, count });
+			});
+			return options;
+		},
+		chargedEnergy() {
+			return this.filteredSessions.reduce((total, s) => total + s.chargedEnergy, 0);
+		},
+		price() {
+			return this.filteredSessions.reduce((total, s) => total + s.price, 0);
+		},
+		hasPrice() {
+			return this.filteredSessions.find((s) => s.price != null) != null;
+		},
+		hasSolarPercentage() {
+			return this.filteredSessions.find((s) => s.solarPercentage != null) != null;
+		},
+		hasCo2() {
+			return this.filteredSessions.find((s) => s.co2PerKWh != null) != null;
+		},
+		pricePerKWh() {
+			return this.price / this.chargedEnergy;
+		},
+		co2PerKWh() {
+			const emittedCo2 = this.filteredSessions.reduce(
+				(total, s) => total + s.chargedEnergy * s.co2PerKWh,
+				0
+			);
+			return emittedCo2 / this.chargedEnergy;
+		},
+		solarPercentage() {
+			const chargedSolarEnergy = this.filteredSessions.reduce(
+				(total, s) => total + s.chargedEnergy * (s.solarPercentage / 100),
+				0
+			);
+			return (100 / this.chargedEnergy) * chargedSolarEnergy;
+		},
+		loadpoints() {
+			return [...new Set(this.currentSessions.map((s) => s.loadpoint))];
+		},
 		vehicles() {
+			return [...new Set(this.currentSessions.map((s) => s.vehicle))];
+		},
+		vehiclesObjects() {
 			return (
 				store.state.vehicles?.map((v, index) => {
 					return { id: index, title: v };
@@ -185,70 +350,108 @@ export default {
 		currency() {
 			return store.state.currency;
 		},
+		headline() {
+			const date = new Date();
+			date.setMonth(this.month - 1);
+			date.setFullYear(this.year);
+			return this.fmtMonthYear(date);
+		},
+		csvLink() {
+			return this.csvHrefLink(this.year, this.month);
+		},
+		csvTotalLink() {
+			return this.csvHrefLink();
+		},
+		prevDate() {
+			const date = new Date();
+			date.setFullYear(this.year);
+			date.setMonth(this.month - 2);
+			return date;
+		},
+		prevYearMonth() {
+			return { year: this.prevDate.getFullYear(), month: this.prevDate.getMonth() + 1 };
+		},
+		prevMonthName() {
+			return this.fmtMonth(this.prevDate);
+		},
+		prevMonthNameShort() {
+			return this.fmtMonth(this.prevDate, true);
+		},
+		nextDate() {
+			const date = new Date();
+			date.setFullYear(this.year);
+			date.setMonth(this.month);
+			return date;
+		},
+		nextYearMonth() {
+			return { year: this.nextDate.getFullYear(), month: this.nextDate.getMonth() + 1 };
+		},
+		nextMonthName() {
+			return this.fmtMonth(this.nextDate);
+		},
+		nextMonthNameShort() {
+			return this.fmtMonth(this.nextDate, true);
+		},
+		hasNext() {
+			const now = new Date();
+			return this.year < now.getFullYear() || this.month < now.getMonth() + 1;
+		},
+		hasPrev() {
+			const length = this.sessions.length;
+			if (length === 0) {
+				return false;
+			}
+			const first = new Date(this.sessions[length - 1].created);
+			return this.year > first.getFullYear() || this.month > first.getMonth() + 1;
+		},
 	},
 	mounted() {
 		this.loadSessions();
 	},
 	methods: {
+		filterByLoadpoint(session) {
+			return !this.loadpointFilter || session.loadpoint === this.loadpointFilter;
+		},
+		filterByVehicle(session) {
+			return !this.vehicleFilter || session.vehicle === this.vehicleFilter;
+		},
+		filterCountForVehicle(vehicle) {
+			return this.currentSessions
+				.filter(this.filterByLoadpoint)
+				.filter((s) => !vehicle || s.vehicle === vehicle).length;
+		},
+		filterCountForLoadpoint(loadpoint) {
+			return this.currentSessions
+				.filter(this.filterByVehicle)
+				.filter((s) => !loadpoint || s.loadpoint === loadpoint).length;
+		},
+		changeLoadpointFilter(event) {
+			const loadpoint = event.target.value || undefined;
+			this.$router.push({ query: { ...this.$route.query, loadpoint } });
+		},
+		changeVehicleFilter(event) {
+			const vehicle = event.target.value || undefined;
+			this.$router.push({ query: { ...this.$route.query, vehicle } });
+		},
 		async loadSessions() {
 			const response = await api.get("sessions");
 			this.sessions = response.data?.result;
 		},
-		groupByMonth(sessions) {
-			return sessions.reduce((groups, session) => {
-				const date = new Date(session.created);
-				const month = `${date.getFullYear()}.${date.getMonth() + 1}`;
-				if (!groups[month]) groups[month] = [];
-				groups[month].push(session);
-				return groups;
-			}, {});
-		},
-		groupByLoadpoint(sessions) {
-			return sessions.reduce((groups, session) => {
-				const loadpoint = session.loadpoint;
-				if (!groups[loadpoint]) groups[loadpoint] = [];
-				groups[loadpoint].push(session);
-				return groups;
-			}, {});
-		},
-		totalKWh(sessions) {
-			return sessions.reduce((total, session) => total + session.chargedEnergy, 0) * 1e3;
-		},
-		groupedKWh(by, sessions) {
-			const grouped = sessions.reduce((groups, session) => {
-				const name = session[by];
-				if (!groups[name]) groups[name] = 0;
-				groups[name] += session.chargedEnergy * 1e3;
-				return groups;
-			}, {});
-			const list = Object.entries(grouped).map(([name, energy]) => {
-				return { name, energy };
-			});
-			return list.length >= 2 ? list : [];
-		},
-		formatGroupHeadline(group) {
-			const date = new Date();
-			const [year, month] = group.split(".");
-			date.setMonth(month - 1);
-			date.setFullYear(year);
-			return this.fmtMonthYear(date);
-		},
 		showDetails(sessionId) {
 			this.selectedSessionId = sessionId;
 			const modal = Modal.getOrCreateInstance(document.getElementById("sessionDetailsModal"));
 			modal.show();
 		},
-		csvHrefLink(groupKey) {
-			var url = `./api/sessions?format=csv&lang=${this.$i18n.locale}`;
-			if (groupKey) {
-				const [year, month] = groupKey.split(".");
-				url += `&year=${year}`;
-
-				if (month) {
-					url += `&month=${month}`;
-				}
+		csvHrefLink(year, month) {
+			const params = new URLSearchParams({
+				format: "csv",
+				lang: this.$i18n.locale,
+			});
+			if (year && month) {
+				params.append("year", year);
+				params.append("month", month);
 			}
-			return url;
+			return `./api/sessions?${params.toString()}`;
 		},
 	},
 };
@@ -260,27 +463,13 @@ export default {
 	position: relative;
 	top: -2px;
 }
-.breakdown {
-	list-style: none;
-}
-
-.breakdown-item {
-	white-space: nowrap;
-}
-
-/* breakpoint sm */
-@media (min-width: 576px) {
-	.breakdown-item:after {
-		content: ", ";
-		white-space: wrap;
-		margin-right: 0.25rem;
-	}
-	.breakdown-item:last-child:after {
-		content: "";
-	}
-}
-
-.breakdown:empty {
-	display: none;
+.custom-select {
+	left: 0;
+	top: 0;
+	bottom: 0;
+	right: 0;
+	position: absolute;
+	opacity: 0;
+	-webkit-appearance: menulist-button;
 }
 </style>
diff --git a/charger/alfen.go b/charger/alfen.go
index ba5c7f96..7d0412f8 100644
--- a/charger/alfen.go
+++ b/charger/alfen.go
@@ -129,7 +129,7 @@ func (wb *Alfen) Status() (api.ChargeStatus, error) {
 
 	switch r := rune(b[0]); r {
 	case 'A', 'B', 'D', 'E', 'F':
-		return api.ChargeStatusString(string(r))
+		return api.ChargeStatusStringWithMapping(string(r), map[api.ChargeStatus]api.ChargeStatus{api.StatusE: api.StatusA})
 	case 'C':
 		// C1 is "connected"
 		if rune(b[1]) == '1' {
diff --git a/charger/easee.go b/charger/easee.go
index 3953b4dd..64af9a45 100644
--- a/charger/easee.go
+++ b/charger/easee.go
@@ -25,6 +25,7 @@ import (
 	"net/http"
 	"os"
 	"strconv"
+	"strings"
 	"sync"
 	"time"
 
@@ -57,8 +58,9 @@ type Easee struct {
 	phaseMode             int
 	currentPower, sessionEnergy, totalEnergy,
 	currentL1, currentL2, currentL3 float64
-	rfid string
-	lp   loadpoint.API
+	rfid     string
+	lp       loadpoint.API
+	respChan chan easee.SignalRCommandResponse
 }
 
 func init() {
@@ -96,11 +98,12 @@ func NewEasee(user, password, charger string, timeout time.Duration) (*Easee, er
 	}
 
 	c := &Easee{
-		Helper:  request.NewHelper(log),
-		charger: charger,
-		log:     log,
-		current: 6, // default current
-		done:    make(chan struct{}),
+		Helper:   request.NewHelper(log),
+		charger:  charger,
+		log:      log,
+		current:  6, // default current
+		done:     make(chan struct{}),
+		respChan: make(chan easee.SignalRCommandResponse),
 	}
 
 	c.Client.Timeout = timeout
@@ -320,6 +323,11 @@ func (c *Easee) CommandResponse(i json.RawMessage) {
 		return
 	}
 	c.log.TRACE.Printf("CommandResponse %s: %+v", res.SerialNumber, res)
+
+	select {
+	case c.respChan <- res:
+	default:
+	}
 }
 
 func (c *Easee) chargers() ([]easee.Charger, error) {
@@ -379,11 +387,9 @@ func (c *Easee) Enable(enable bool) error {
 		}
 
 		uri := fmt.Sprintf("%s/chargers/%s/settings", easee.API, c.charger)
-		resp, err := c.Post(uri, request.JSONContent, request.MarshalJSON(data))
-		if err != nil {
+		if err := c.postJSONAndWait(uri, data); err != nil {
 			return err
 		}
-		resp.Body.Close()
 	}
 
 	// resume/stop charger
@@ -391,10 +397,74 @@ func (c *Easee) Enable(enable bool) error {
 	if enable {
 		action = easee.ChargeResume
 	}
+
 	uri := fmt.Sprintf("%s/chargers/%s/commands/%s", easee.API, c.charger, action)
-	_, err := c.Post(uri, request.JSONContent, nil)
+	if err := c.postJSONAndWait(uri, nil); err != nil {
+		return err
+	}
 
-	return err
+	if enable {
+		// reset currents after enable, as easee automatically resets to maxA
+		return c.MaxCurrent(int64(c.current))
+	}
+
+	return nil
+}
+
+// posts JSON to the Easee API endpoint and waits for the async response
+func (c *Easee) postJSONAndWait(uri string, data any) error {
+	resp, err := c.Post(uri, request.JSONContent, request.MarshalJSON(data))
+	if err != nil {
+		return err
+	}
+	defer resp.Body.Close()
+
+	if resp.StatusCode == 200 { //sync call
+		return nil
+	}
+
+	if resp.StatusCode == 202 { //async call, wait for response
+		var cmd easee.RestCommandResponse
+
+		if strings.Contains(uri, "/commands/") { //command endpoint
+			if err := json.NewDecoder(resp.Body).Decode(&cmd); err != nil {
+				return err
+			}
+		} else { //settings endpoint
+			var cmdArr []easee.RestCommandResponse
+			if err := json.NewDecoder(resp.Body).Decode(&cmdArr); err != nil {
+				return err
+			}
+
+			if len(cmdArr) != 0 {
+				cmd = cmdArr[0]
+			}
+		}
+
+		if cmd.Ticks == 0 { //Easee API thinks this was a noop
+			return nil
+		}
+		return c.waitForTickResponse(cmd.Ticks)
+	}
+
+	// all other response codes lead to an error
+	return fmt.Errorf("invalid status: %d", resp.StatusCode)
+}
+
+func (c *Easee) waitForTickResponse(expectedTick int64) error {
+	for {
+		select {
+		case cmdResp := <-c.respChan:
+			if cmdResp.Ticks == expectedTick {
+				if !cmdResp.WasAccepted {
+					return fmt.Errorf("command rejected: %d", cmdResp.Ticks)
+				}
+				return nil
+			}
+		case <-time.After(10 * time.Second):
+			return api.ErrTimeout
+		}
+	}
 }
 
 // MaxCurrent implements the api.Charger interface
@@ -405,21 +475,16 @@ func (c *Easee) MaxCurrent(current int64) error {
 	}
 
 	uri := fmt.Sprintf("%s/chargers/%s/settings", easee.API, c.charger)
-	resp, err := c.Post(uri, request.JSONContent, request.MarshalJSON(data))
-	if err == nil {
-		resp.Body.Close()
-		if resp.StatusCode == 202 && resp.ContentLength <= 2 {
-			// no tick id, Easee effectively ignored this update
-			return api.ErrMustRetry
-		}
-
-		c.mux.Lock()
-		defer c.mux.Unlock()
-		c.current = cur
-		c.currentUpdated = time.Now()
+	if err := c.postJSONAndWait(uri, data); err != nil {
+		return err
 	}
 
-	return err
+	c.mux.Lock()
+	defer c.mux.Unlock()
+	c.current = cur
+	c.currentUpdated = time.Now()
+
+	return nil
 }
 
 var _ api.PhaseSwitcher = (*Easee)(nil)
@@ -456,10 +521,7 @@ func (c *Easee) Phases1p3p(phases int) error {
 			data.DynamicCircuitCurrentP3 = &max3
 		}
 
-		var resp *http.Response
-		if resp, err = c.Post(uri, request.JSONContent, request.MarshalJSON(data)); err == nil {
-			resp.Body.Close()
-		}
+		err = c.postJSONAndWait(uri, data)
 	} else {
 		// charger level
 		if phases == 3 {
@@ -474,10 +536,7 @@ func (c *Easee) Phases1p3p(phases int) error {
 
 			uri := fmt.Sprintf("%s/chargers/%s/settings", easee.API, c.charger)
 
-			var resp *http.Response
-			if resp, err = c.Post(uri, request.JSONContent, request.MarshalJSON(data)); err == nil {
-				resp.Body.Close()
-			}
+			err = c.postJSONAndWait(uri, data)
 		}
 	}
 
@@ -553,10 +612,8 @@ func (c *Easee) updateSmartCharging() {
 		}
 
 		uri := fmt.Sprintf("%s/chargers/%s/settings", easee.API, c.charger)
-		req, err := request.New(http.MethodPost, uri, request.MarshalJSON(data), request.JSONEncoding)
-		if err == nil {
-			_, err = c.DoBody(req)
-		}
+
+		err := c.postJSONAndWait(uri, data)
 		if err != nil {
 			c.log.WARN.Printf("smart charging: %v", err)
 			return
diff --git a/charger/innogy.go b/charger/innogy.go
index 7c67c011..7d8c1fa1 100644
--- a/charger/innogy.go
+++ b/charger/innogy.go
@@ -98,7 +98,7 @@ func (wb *Innogy) Status() (api.ChargeStatus, error) {
 
 	switch r := rune(b[0]); r {
 	case 'A', 'B', 'D', 'E', 'F':
-		return api.ChargeStatusString(string(r))
+		return api.ChargeStatusStringWithMapping(string(r), map[api.ChargeStatus]api.ChargeStatus{api.StatusE: api.StatusA})
 	case 'C':
 		// C1 is "connected"
 		if rune(b[1]) == '1' {
diff --git a/charger/ocpp/cs.go b/charger/ocpp/cs.go
index a3a18857..e13ce7c3 100644
--- a/charger/ocpp/cs.go
+++ b/charger/ocpp/cs.go
@@ -102,27 +102,3 @@ func (cs *CS) ChargePointDisconnected(chargePoint ocpp16.ChargePointConnection)
 		}
 	}
 }
-
-func (cs *CS) Debug(args ...interface{}) {
-	cs.log.TRACE.Println(args...)
-}
-
-func (cs *CS) Debugf(fmt string, args ...interface{}) {
-	cs.log.TRACE.Printf(fmt, args...)
-}
-
-func (cs *CS) Info(args ...interface{}) {
-	cs.log.TRACE.Println(args...)
-}
-
-func (cs *CS) Infof(fmt string, args ...interface{}) {
-	cs.log.TRACE.Printf(fmt, args...)
-}
-
-func (cs *CS) Error(args ...interface{}) {
-	cs.log.TRACE.Println(args...)
-}
-
-func (cs *CS) Errorf(fmt string, args ...interface{}) {
-	cs.log.TRACE.Printf(fmt, args...)
-}
diff --git a/charger/ocpp/cs_log.go b/charger/ocpp/cs_log.go
new file mode 100644
index 00000000..c4d047e8
--- /dev/null
+++ b/charger/ocpp/cs_log.go
@@ -0,0 +1,36 @@
+package ocpp
+
+import (
+	"fmt"
+	"strings"
+)
+
+func (cs *CS) print(s string) {
+	if strings.Contains(s, "JSON message") {
+		cs.log.TRACE.Println(s)
+	}
+}
+
+func (cs *CS) Debug(args ...interface{}) {
+	cs.print(fmt.Sprintln(args...))
+}
+
+func (cs *CS) Debugf(f string, args ...interface{}) {
+	cs.print(fmt.Sprintf(f, args...))
+}
+
+func (cs *CS) Info(args ...interface{}) {
+	cs.print(fmt.Sprintln(args...))
+}
+
+func (cs *CS) Infof(f string, args ...interface{}) {
+	cs.print(fmt.Sprintf(f, args...))
+}
+
+func (cs *CS) Error(args ...interface{}) {
+	cs.print(fmt.Sprintln(args...))
+}
+
+func (cs *CS) Errorf(f string, args ...interface{}) {
+	cs.print(fmt.Sprintf(f, args...))
+}
diff --git a/charger/openevse/api.go b/charger/openevse/api.go
index f8b2aaf5..0c10fe7f 100644
--- a/charger/openevse/api.go
+++ b/charger/openevse/api.go
@@ -2106,7 +2106,7 @@ type GetStatusResponse struct {
 		Voltage *float32 `json:"voltage,omitempty"`
 
 		// The total amount of energy transfered, in W/hour
-		Watthour *int `json:"watthour,omitempty"`
+		Watthour *float32 `json:"watthour,omitempty"`
 
 		// The amount of energy transfered in this charge session, in W/sec
 		Wattsec *float32 `json:"wattsec,omitempty"`
@@ -2285,7 +2285,7 @@ type StatusUpdatesResponse struct {
 		Voltage *float32 `json:"voltage,omitempty"`
 
 		// The total amount of energy transfered, in W/hour
-		Watthour *int `json:"watthour,omitempty"`
+		Watthour *float32 `json:"watthour,omitempty"`
 
 		// The amount of energy transfered in this charge session, in W/sec
 		Wattsec *float32 `json:"wattsec,omitempty"`
@@ -3284,7 +3284,7 @@ func ParseGetStatusResponse(rsp *http.Response) (*GetStatusResponse, error) {
 			Voltage *float32 `json:"voltage,omitempty"`
 
 			// The total amount of energy transfered, in W/hour
-			Watthour *int `json:"watthour,omitempty"`
+			Watthour *float32 `json:"watthour,omitempty"`
 
 			// The amount of energy transfered in this charge session, in W/sec
 			Wattsec *float32 `json:"wattsec,omitempty"`
@@ -3471,7 +3471,7 @@ func ParseStatusUpdatesResponse(rsp *http.Response) (*StatusUpdatesResponse, err
 			Voltage *float32 `json:"voltage,omitempty"`
 
 			// The total amount of energy transfered, in W/hour
-			Watthour *int `json:"watthour,omitempty"`
+			Watthour *float32 `json:"watthour,omitempty"`
 
 			// The amount of energy transfered in this charge session, in W/sec
 			Wattsec *float32 `json:"wattsec,omitempty"`
diff --git a/i18n/de.toml b/i18n/de.toml
index 5d7cb088..d5f12180 100644
--- a/i18n/de.toml
+++ b/i18n/de.toml
@@ -285,10 +285,13 @@ title = "Ladevorgang"
 [sessions]
 avgPrice = "Ø Preis"
 co2 = "Ø CO₂"
+csvMonth = "Download {month} CSV"
+csvTotal = "Download total CSV"
 date = "Anfang"
 downloadCsv = "Als CSV herunterladen"
 energy = "Geladen"
 loadpoint = "Ladepunkt"
+noData = "Noch keine Ladevorgänge in diesem Monat."
 price = "Σ Preis"
 reallyDelete = "Wollen Sie diesen Ladevorgang wirklich löschen?"
 solar = "Sonne"
@@ -306,6 +309,11 @@ meterstop = "Endzählerstand (kWh)"
 odometer = "Kilometerstand (km)"
 vehicle = "Fahrzeug"
 
+[sessions.filter]
+allLoadpoints = "Alle Ladepunkte"
+allVehicles = "Alle Fahrzeuge"
+filter = "filtern"
+
 [settings]
 title = "Einstellungen"
 
diff --git a/i18n/en.toml b/i18n/en.toml
index 58357384..f2735752 100644
--- a/i18n/en.toml
+++ b/i18n/en.toml
@@ -285,10 +285,13 @@ title = "Charging Session"
 [sessions]
 avgPrice = "Ø Price"
 co2 = "Ø CO₂"
+csvMonth = "Download {month} CSV"
+csvTotal = "Download total CSV"
 date = "Finished"
 downloadCsv = "Download as CSV"
 energy = "Charged"
 loadpoint = "Charging point"
+noData = "No charging sessions this month."
 price = "Σ Price"
 reallyDelete = "Do you really want to delete this session?"
 solar = "Solar"
@@ -306,6 +309,11 @@ meterstop = "Meter stop (kWh)"
 odometer = "Mileage (km)"
 vehicle = "Vehicle"
 
+[sessions.filter]
+allLoadpoints = "all charging points"
+allVehicles = "all vehicles"
+filter = "filter"
+
 [settings]
 title = "Settings"
 
diff --git a/i18n/es.toml b/i18n/es.toml
index e1d1e82f..1bbce296 100644
--- a/i18n/es.toml
+++ b/i18n/es.toml
@@ -65,12 +65,12 @@ optInMoreDetailsLink = "aquí"
 optInSponsorship = "Se requiere patrocinio."
 
 [footer.version]
-availableLong = "Actualizaciones disponibles"
+availableLong = "nueva versión disponible"
 modalCancel = "Cancelar"
 modalDownload = "Descargar"
 modalInstalledVersion = "Versión instalada"
 modalNoReleaseNotes = "No hay notas de lanzamiento disponibles. Puedes encontrar más información sobre la nueva versión aquí:"
-modalTitle = "Actualizaciones disponibles"
+modalTitle = "Nueva versión disponible"
 modalUpdate = "Instalar"
 modalUpdateNow = "Instalar ahora"
 modalUpdateStarted = "Después de la actualización, evcc se reiniciará."
@@ -202,7 +202,7 @@ co2Limit = "Límite de CO₂ del {co2}"
 costLimitIgnore = "El {limit} configurado será ignorado durante este periodo."
 descriptionEnergy = "¿Hasta cuándo deben haberse cargado {targetEnergy} al vehículo?"
 descriptionSoc = "¿Hasta cuándo debe haberse cargarse el vehículo al {targetSoc}%?"
-inactiveLabel = "hora objetivo"
+inactiveLabel = "Tiempo objetivo"
 modalTitle = "Establecer el tiempo objetivo"
 planDuration = "Tiempo de carga"
 planPeriodLabel = "Período"
@@ -213,7 +213,7 @@ remove = "retirar"
 setTargetTime = "niguno"
 targetIsInThePast = "Elige un tiempo en el futuro, Marty."
 targetIsTooFarInTheFuture = "Ajustaremos el plan en cuanto sepamos más sobre el futuro."
-title = "hora objetivo"
+title = "Tiempo objetivo"
 today = "hoy"
 tomorrow = "mañana"
 update = "Actualizar"
@@ -294,10 +294,13 @@ title = "Cargando la sesión"
 [sessions]
 avgPrice = "Precio Ø"
 co2 = "CO₂ Ø"
+csvMonth = "Descargar el {month} CSV"
+csvTotal = "Descargar todos los CSV"
 date = "Acabado"
 downloadCsv = "Descargar como CSV"
 energy = "Cargado"
 loadpoint = "Punto de carga"
+noData = "No hay sesiones de carga este mes."
 price = "Σ Precio"
 reallyDelete = "¿De verdad quieres eliminar esta sesión?"
 solar = "Solar"
@@ -315,6 +318,11 @@ meterstop = "Parón de metro (kWh)"
 odometer = "Kilometraje (km)"
 vehicle = "Vehículo"
 
+[sessions.filter]
+allLoadpoints = "todos los puntos de recarga"
+allVehicles = "todos los vehiculos"
+filter = "filtrar"
+
 [settings]
 title = "Ajustes"
 
diff --git a/i18n/lt.toml b/i18n/lt.toml
index 09772c29..a2495a9a 100644
--- a/i18n/lt.toml
+++ b/i18n/lt.toml
@@ -38,10 +38,10 @@ footerShort = "{percent}% saulės"
 modalTitle = "Įkrovimo energijos apžvalga"
 percentGrid = "{grid} kWh tinklo"
 percentSelf = "{self} kWh saulės"
-percentTitle = "Saulės energija"
+percentTitle = "Saulės Energija"
 priceFeedIn = "{feedInPrice} eksporto"
 priceGrid = "{gridPrice} tinklo"
-priceTitle = "Energijos kaina"
+priceTitle = "Energijos Kaina"
 savingsComparedToGrid = "palyginus su tinklu"
 savingsTitle = "Sutaupyta"
 savingsTotalEnergy = "{total} kWh įkrauta"
@@ -175,7 +175,7 @@ label = "Min. Srovė"
 
 [main.loadpointSettings.minSoc]
 description = "Minimali įkrova. Automobilis įkraunamas „Greitai” iki {0}% nustatyme „Saulė”, toliau įkraunamas tik saulės energijos pertekliumi."
-label = "Minimali įkrova"
+label = "Minimali įkrova %"
 
 [main.loadpointSettings.phasesConfigured]
 label = "Fazės"
@@ -202,7 +202,7 @@ co2Limit = "CO₂ riba iš {co2}"
 costLimitIgnore = "Šiuo laikotarpiu sukonfigūruotas {limit} bus ignoruojamas."
 descriptionEnergy = "Iki kada {targetEnergy} turėtų būti įkrauta į automobilį?"
 descriptionSoc = "Kada automobilis turėtų būti įkrautas iki {targetSoc}%?"
-inactiveLabel = "Planinis įkrovimas"
+inactiveLabel = "Suplanuotas laikas"
 modalTitle = "Nustatyti įkrovimo pabaigos laiką"
 planDuration = "Įkrovimo laikas"
 planPeriodLabel = "Periodas"
@@ -213,7 +213,7 @@ remove = "Panaikinti"
 setTargetTime = "nesuplanuotas"
 targetIsInThePast = "Pasirinkite laiką ateityje."
 targetIsTooFarInTheFuture = "Pakoreguosime planą, kai tik gausime naujų duomenų."
-title = "Planinis įkrovimas"
+title = "Planinis Įkrovimas"
 today = "šiandien"
 tomorrow = "rytoj"
 update = "Atnaujinti"
@@ -284,9 +284,9 @@ date = "Periodas"
 delete = "Ištrinti"
 finished = "Pabaigta"
 meter = "Skaitiklis"
-meterstart = "Skaitiklis pradžia (kWh)"
-meterstop = "Skaitiklis pabaiga (kWh)"
-odometer = "Odometras (km)"
+meterstart = "Skaitiklis pradžia"
+meterstop = "Skaitiklis pabaiga"
+odometer = "Odometras"
 price = "Kaina"
 started = "Pradėta"
 title = "Įkrovimo sesija"
@@ -294,10 +294,13 @@ title = "Įkrovimo sesija"
 [sessions]
 avgPrice = "Ø Kaina"
 co2 = "Ø CO₂"
+csvMonth = "Atsisiųsti {month} CSV"
+csvTotal = "Atsisiųsti visą CSV"
 date = "Pabaigta"
 downloadCsv = "Atsisiųsti CSV failą"
 energy = "Įkrauta"
 loadpoint = "Įkroviklis"
+noData = "Šį mėnesį įkrovimų nebuvo."
 price = "Σ Kaina"
 reallyDelete = "Ar tikrai norite ištrinti šią įkrovimo sesiją?"
 solar = "Saulės"
@@ -306,7 +309,7 @@ vehicle = "Automobilis"
 
 [sessions.csv]
 chargedenergy = "Energija (kWh)"
-created = "Pradėta"
+created = "Sukurta"
 finished = "Pabaigta"
 identifier = "Identifikatorius"
 loadpoint = "Įkroviklis"
@@ -315,6 +318,11 @@ meterstop = "Skaitiklis pabaiga (kWh)"
 odometer = "Odometras (km)"
 vehicle = "Automobilis"
 
+[sessions.filter]
+allLoadpoints = "visi įkrovikliai"
+allVehicles = "visi automobiliai"
+filter = "filtruoti"
+
 [settings]
 title = "Nustatymai"
 
diff --git a/i18n/nl.toml b/i18n/nl.toml
index f8d57bf0..48c0a89d 100644
--- a/i18n/nl.toml
+++ b/i18n/nl.toml
@@ -1,9 +1,10 @@
 [batterySettings]
-batteryLevel = "Batterijniveau"
+batteryLevel = "Batterij-niveau"
 capacity = "{energy} van {total}"
-legendBottomName = "prioriteit huis"
+legendBottomName = "huis heeft prioriteit"
 legendBottomSubline = "niet gebruikt voor opladen"
 legendMiddleName = "eerst voertuig"
+legendMiddleSubline = "dan het huis"
 legendTitle = "Hoe moet zonne-energie gebruikt worden?"
 legendTopAutostart = "start automatisch"
 legendTopName = "batterij-ondersteund opladen"
@@ -11,31 +12,37 @@ legendTopSubline = "zonder onderbrekingen"
 modalTitle = "Batterij-instellingen"
 note = "Opgelet: Deze instellingen beïnvloeden enkel de zonnemodus. Oplaadgedrag wordt overeenkomstig aangepast."
 
+[batterySettings.bufferStart]
+full = "als bijna vol"
+low = "als beetje vol"
+medium = "als half vol"
+never = "enkel bij voldoende overschot"
+
 [footer]
 
 [footer.community]
-greenEnergy = "Zonne"
+greenEnergy = "Zon"
 greenEnergySub1 = "geladen met evcc"
 greenEnergySub2 = "sinds oktober 2022"
-greenShare = "Zonne-energie"
+greenShare = "Zonne-deel"
 greenShareSub1 = "stroom geleverd door"
 greenShareSub2 = "zon, en batterij opslag"
 power = "Laadvermogen"
 powerSub1 = "{activeClients} van {totalClients} deelnemers"
-powerSub2 = "opladen..."
+powerSub2 = "laden..."
 tabTitle = "Live community"
 
 [footer.savings]
 footerLong = "{percent}% zonne-energie"
-footerShort = "{percent}% PV"
-modalTitle = "Energieoverzicht"
-percentGrid = "{grid} kWh afgenomen"
-percentSelf = "{self} kWh PV"
+footerShort = "{percent}% zon"
+modalTitle = "Oplaadenergie-overzicht"
+percentGrid = "{grid} kWh net"
+percentSelf = "{self} kWh zon"
 percentTitle = "Zonne-energie"
 priceFeedIn = "{feedInPrice} injectie"
-priceGrid = "{gridPrice} afname"
+priceGrid = "{gridPrice} net"
 priceTitle = "Energieprijs"
-savingsComparedToGrid = "i.v.m. netafname"
+savingsComparedToGrid = "in vergelijking met netafname"
 savingsTitle = "Besparing"
 savingsTotalEnergy = "{total} kWh geladen"
 since = "sinds {since}"
@@ -52,10 +59,10 @@ titleNoSponsor = "Sponsor ons"
 titleSponsor = "U bent een sponsor!"
 
 [footer.telemetry]
-optIn = "Ik wil mijn gegevens bijdragen."
+optIn = "Ik wil mijn gegevens delen."
 optInMoreDetails = "Meer details {0}."
 optInMoreDetailsLink = "hier"
-optInSponsorship = "(Sponsoring nodig)"
+optInSponsorship = "Sponsoring nodig."
 
 [footer.version]
 availableLong = "nieuwe versie beschikbaar"
@@ -64,17 +71,29 @@ modalDownload = "Download"
 modalInstalledVersion = "Geïnstalleerde versie"
 modalNoReleaseNotes = "Geen release notes beschikbaar. Meer info over de nieuwe versie:"
 modalTitle = "Nieuwe versie beschikbaar"
-modalUpdate = "Installatie"
+modalUpdate = "Installeren"
 modalUpdateNow = "Nu installeren"
 modalUpdateStarted = "Starten van de nieuwe versie van evcc..."
 modalUpdateStatusStart = "Installatie gestart:"
 
+[gridSettings]
+activeHours = "{charging} van {total}"
+activeHoursLabel = "Actieve uren"
+co2Label = "CO₂ emissie"
+co2Limit = "CO₂ limiet"
+costLimitDescription = "Zet tijdelijk snel-laden in solar mode aan"
+modalTitle = "Smart Grid laden"
+none = "geen"
+priceLabel = "Energieprijs"
+priceLimit = "Prijslimiet"
+
 [header]
 about = "Over"
 blog = "Blog"
 docs = "Documentatie"
 github = "GitHub"
 login = "Voertuig logins"
+needHelp = "Hulp nodig?"
 sessions = "Laadsessies"
 settings = "Instellingen"
 
@@ -83,9 +102,37 @@ auto = "Design: systeem"
 dark = "Design: donker"
 light = "Design: licht"
 
+[help]
+discussionsButton = "GitHub discussies"
+documentationButton = "Documentatie"
+issueButton = "Een bug melden"
+issueDescription = "Vreemd of fout gedrag gevonden?"
+modalTitle = "Hulp nodig?"
+primaryActions = "Werkt iets niet naar behoren? Er zijn goede plaatsen om hulp te krijgen."
+restartButton = "Herstart"
+restartDescription = "Geprobeerd het uit en terug aan te zetten?"
+secondaryActions = "Nog niet gelukt om je probleem op te lossen? Hier zijn enkele hardhandige opties."
+
+[help.restart]
+cancel = "Cancel"
+confirm = "Ja, herstarten!"
+description = "Herstarten niet nodig onder normale omstandigheden. Overweeg een bug in te dienen als je evcc geregeld moet herstarten."
+disclaimer = "Nota: evcc zal afsluiten en rekenen op het besturingssysteem om de service te herstarten."
+modalTitle = "Ben je zeker dat je wil herstarten?"
+
 [main]
 vehicles = "Parking"
 
+[main.chargingPlan]
+activeLabel = "{time}"
+arrivalTab = "Aankomst"
+departureTab = "Vertrek"
+modalTitle = "Laad Plan"
+none = "geen"
+title = "Plan"
+titleMinSoc = "Min lading"
+titleTargetCharge = "Vertrek"
+
 [main.energyflow]
 battery = "Batterij"
 batteryCharge = "Batterij opladen"
@@ -100,13 +147,17 @@ pvProduction = "Productie"
 selfConsumption = "Zelfverbruik"
 
 [main.loadpoint]
+avgPrice = "Ø Prijs"
 charged = "Geladen"
+co2 = "Ø CO₂"
 duration = "Duur"
 fallbackName = "Laadpunt"
-power = "Vermogen"
+power = "Spanning"
+price = "Σ Prijs"
 remaining = "Resterend"
 remoteDisabledHard = "{source}: uitgeschakeld"
 remoteDisabledSoft = "{source}: adaptief PV-laden uitgeschakeld"
+solar = "Zon"
 
 [main.loadpointSettings]
 currents = "Laadstroom"
@@ -130,15 +181,15 @@ label = "Min. lading %"
 label = "Fasen"
 phases_0 = "automatisch omschakelen"
 phases_1 = "1 fase"
-phases_1_hint = "({min} - {max})"
+phases_1_hint = "({min} tot {max})"
 phases_3 = "3 fase"
-phases_3_hint = "({min} - {max})"
+phases_3_hint = "({min} tot {max})"
 
 [main.mode]
-minpv = "Min+Solar"
+minpv = "Min+PV"
 now = "Snel"
 off = "Uit"
-pv = "Solar"
+pv = "PV"
 
 [main.provider]
 login = "log in"
@@ -147,7 +198,9 @@ logout = "afmelden"
 [main.targetCharge]
 activate = "Activeren"
 activeLabel = "{time}"
-descriptionEnergy = "Tot wanneer moet {targetEnergy} in het voertuig worden geladen?"
+co2Limit = "CO₂ limiet van {co2}"
+costLimitIgnore = "De geconfigureerde {limit} zal genegeerd worden tijdens deze periode."
+descriptionEnergy = "Tegen wanneer moet {targetEnergy} in het voertuig geladen zijn?"
 descriptionSoc = "Wanneer moet het voertuig geladen zijn naar {targetSoc}%?"
 inactiveLabel = "Ingestelde tijd"
 modalTitle = "Stel tijd in"
@@ -155,21 +208,22 @@ planDuration = "Oplaadtijd"
 planPeriodLabel = "Periode"
 planPeriodValue = "{start} tot {end}"
 planUnknown = "nog niet bekend"
+priceLimit = "prijslimiet van {price}"
 remove = "Verwijder"
 setTargetTime = "geen"
 targetIsInThePast = "Kies een tijd in de toekomst, Marty."
-targetIsTooFarInTheFuture = "We passen het plan aan wanneer we meer weten over de toekomst."
+targetIsTooFarInTheFuture = "We passen het plan aan zodra we meer weten over de toekomst."
 title = "Doeltijd"
 today = "Vandaag"
 tomorrow = "Morgen"
-update = "Update"
+update = "Updaten"
 
 [main.targetChargePlan]
 chargeDuration = "Oplaadtijd"
 co2Label = "CO₂ emissie Ø"
 priceLabel = "Energieprijs"
 timeRange = "{day} {range} h"
-unknownPrice = "onbekend"
+unknownPrice = "nog onbekend"
 
 [main.targetEnergy]
 label = "Limiet"
@@ -178,13 +232,13 @@ noLimit = "geen"
 [main.vehicle]
 addVehicle = "Voertuig toevoegen"
 changeVehicle = "Wijzig voertuig"
-detectionActive = "Detecteer voertuig..."
+detectionActive = "Detecteren voertuig bezig..."
 fallbackName = "Voertuig"
 moreActions = "Meer acties"
 none = "Geen voertuig"
 targetSoc = "Limiet"
-unknown = "Gast"
-vehicleSoc = "Opladen"
+unknown = "Gast voertuig"
+vehicleSoc = "Lading"
 
 [main.vehicleSoc]
 charging = "laden"
@@ -195,13 +249,17 @@ vehicleTarget = "Voertuiglimiet: {soc}%"
 
 [main.vehicleStatus]
 charging = "Opladen..."
+cheapEnergyCharging = "Goedkope energie beschikbaar. Aan het laden…"
+cleanEnergyCharging = "Groene energie beschikbaar. Aan het laden…"
+climating = "Voor-conditionering gedetecteerd."
 connected = "Verbonden."
 disconnected = "Niet verbonden."
+guard = "Schakelbeveiliging actief voor {remaining}."
 minCharge = "Minimaal opladen naar {soc}%."
 pvDisable = "Onvoldoende PV overschot beschikbaar. Pauzeren over {remaining}..."
 pvEnable = "Voldoende PV overschot beschikbaar. Starten over {remaining}..."
-scale1p = "Wisselen naar 1 fase over {remaining}..."
-scale3p = "Wisselen naar 3 fasen over {remaining}..."
+scale1p = "Wisselen naar 1-fase over {remaining}..."
+scale3p = "Wisselen naar 3-fasen over {remaining}..."
 targetChargeActive = "Doelladen actief..."
 targetChargePlanned = "Doelladen begint om {time}."
 targetChargeWaitForVehicle = "Doellading gereed. Wachten op voertuig..."
@@ -219,20 +277,28 @@ reload = "Verversen?"
 
 [session]
 cancel = "Annuleer"
+co2 = "CO₂"
+date = "Periode"
 delete = "Verwijder"
 finished = "Afgerond"
+meter = "Meter"
 meterstart = "Meter start"
 meterstop = "Meter stop"
 odometer = "Kilometerstand"
+price = "Prijs"
 started = "Gestart"
 title = "Oplaadsessie"
 
 [sessions]
-date = "Periode"
-downloadCsv = "Download CSV"
+avgPrice = "Ø Prijs"
+co2 = "Ø CO₂"
+date = "Start"
+downloadCsv = "Download als CSV"
 energy = "Geladen"
 loadpoint = "Oplaadpunt"
+price = "Σ Prijs"
 reallyDelete = "Wilt u deze sessie echt verwijderen?"
+solar = "Zon"
 title = "Oplaadsessies"
 vehicle = "Voertuig"
 
@@ -264,6 +330,11 @@ value = "Toon experimentele UI functies."
 auto = "Automatisch"
 label = "Taal"
 
+[settings.sponsorToken]
+expires = "Je sponsor token vervalt {inXDays}. {getNewToken} en update je configuratie bestand."
+getNew = "Neem een nieuwe"
+hint = "Nota: We zullen dit in de toekomst automatiseren."
+
 [settings.telemetry]
 label = "Telemetrie"
 
diff --git a/i18n/pt.toml b/i18n/pt.toml
index 1bad1bdb..82dd5185 100644
--- a/i18n/pt.toml
+++ b/i18n/pt.toml
@@ -294,10 +294,13 @@ title = "Sessão de carga"
 [sessions]
 avgPrice = "Preço Ø"
 co2 = "CO₂ Ø"
-date = "Período"
+csvMonth = "Download {month} CSV"
+csvTotal = "Download tudo CSV"
+date = "Terminado"
 downloadCsv = "Download como CSV"
 energy = "Carregado"
 loadpoint = "Ponto de carga"
+noData = "Nenhuma sessão de carregamento este mês."
 odometer = "Quilometragem"
 price = "Preço Σ"
 reallyDelete = "Você realmente quer excluir esta sessão?"
@@ -316,6 +319,11 @@ meterstop = "Contagem final (kWh)"
 odometer = "Quilometragem (km)"
 vehicle = "Veículo"
 
+[sessions.filter]
+allLoadpoints = "todos pontos de carregamento"
+allVehicles = "todos veículos"
+filter = "filtro"
+
 [settings]
 title = "Configuração"
 
diff --git a/i18n/ro.toml b/i18n/ro.toml
index 7195cd13..c578fc76 100644
--- a/i18n/ro.toml
+++ b/i18n/ro.toml
@@ -3,6 +3,20 @@ batteryLevel = "Nivelul bateriei"
 capacity = "{energy} din {total}"
 legendBottomName = "prioritate casa"
 legendBottomSubline = "neutilizat pentru incarcare"
+legendMiddleName = "in primul rand vehiculul"
+legendMiddleSubline = "in al doilea rand casa"
+legendTitle = "Cum ar trebui utilizata energia solara?"
+legendTopAutostart = "porneste automat"
+legendTopName = "Incarcare ajutata de baterie"
+legendTopSubline = "fara intreruperi"
+modalTitle = "Setari baterie"
+note = "Nota: Aceste setari afecteaza doar modul solar. Procesul de incarcare este ajustat corespunzator"
+
+[batterySettings.bufferStart]
+full = "cand este aproape plin"
+low = "cand este destul de plin"
+medium = "cand este pe jumatate plin"
+never = "numai cand exista surplus suficient"
 
 [footer]
 
diff --git a/i18n/uk.toml b/i18n/uk.toml
index 067d6e86..e1fe0884 100644
--- a/i18n/uk.toml
+++ b/i18n/uk.toml
@@ -287,10 +287,13 @@ title = "Сеанс зарядки"
 [sessions]
 avgPrice = "Ø Ціна"
 co2 = "Ø CO₂"
+csvMonth = "Завантажити {month} CSV"
+csvTotal = "Завантажити повний CSV"
 date = "Завершено"
 downloadCsv = "Завантажити як CSV"
 energy = "Заряджено"
 loadpoint = "Точка зарядки"
+noData = "Ніяких зарядних сесій цього місяця"
 price = "Σ Ціна"
 reallyDelete = "Ви дійсно хочете видалити цей сеанс?"
 solar = "Сонячна"
@@ -308,6 +311,11 @@ meterstop = "Лічильник зупинки (кВт/год)"
 odometer = "Пробіг (км)"
 vehicle = "Транспортний засіб"
 
+[sessions.filter]
+allLoadpoints = "всі точки зарядки"
+allVehicles = "всі транспортні засоби"
+filter = "фільтр"
+
 [settings]
 title = "Налаштування"
 
diff --git a/package-lock.json b/package-lock.json
index e0df2122..3407b4dd 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -10,6 +10,7 @@
         "@babel/core": "^7.17.2",
         "@h2d2/shopicons": "^1.2.0",
         "@histoire/plugin-vue": "^0.16.1",
+        "@playwright/test": "^1.34.3",
         "@popperjs/core": "^2.11.5",
         "@vitejs/plugin-legacy": "^4.0.2",
         "@vitejs/plugin-vue": "^4.1.0",
@@ -42,7 +43,8 @@
         "vue-hot-reload-api": "^2.3.4",
         "vue-i18n": "^9.2.2",
         "vue-meta": "^3.0.0-alpha.2",
-        "vue-router": "^4.0.12"
+        "vue-router": "^4.0.12",
+        "wait-on": "^7.0.1"
       },
       "engines": {
         "node": ">=18.0.0",
@@ -2081,6 +2083,19 @@
       "resolved": "https://registry.npmjs.org/@h2d2/shopicons/-/shopicons-1.8.1.tgz",
       "integrity": "sha512-vnL8GncQJp+Pqv0yFpIcGvRNzaAXeZDT28tM427dy/e1IShafVNnB9fi9b4sbqlKwsg875vQSq7zUgm36oS9TA=="
     },
+    "node_modules/@hapi/hoek": {
+      "version": "9.3.0",
+      "resolved": "https://registry.npmjs.org/@hapi/hoek/-/hoek-9.3.0.tgz",
+      "integrity": "sha512-/c6rf4UJlmHlC9b5BaNvzAcFv7HZ2QHaV0D4/HNlBdvFnvQq8RI4kYdhyPCl7Xj+oWvTWQ8ujhqS53LIgAe6KQ=="
+    },
+    "node_modules/@hapi/topo": {
+      "version": "5.1.0",
+      "resolved": "https://registry.npmjs.org/@hapi/topo/-/topo-5.1.0.tgz",
+      "integrity": "sha512-foQZKJig7Ob0BMAYBfcJk8d77QtOe7Wo4ox7ff1lQYoNNAb6jwcY1ncdoy2e9wQZzvNy7ODZCYJkK8kzmcAnAg==",
+      "dependencies": {
+        "@hapi/hoek": "^9.0.0"
+      }
+    },
     "node_modules/@histoire/app": {
       "version": "0.16.1",
       "resolved": "https://registry.npmjs.org/@histoire/app/-/app-0.16.1.tgz",
@@ -2356,6 +2371,24 @@
         "node": ">= 8"
       }
     },
+    "node_modules/@playwright/test": {
+      "version": "1.34.3",
+      "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.34.3.tgz",
+      "integrity": "sha512-zPLef6w9P6T/iT6XDYG3mvGOqOyb6eHaV9XtkunYs0+OzxBtrPAAaHotc0X+PJ00WPPnLfFBTl7mf45Mn8DBmw==",
+      "dependencies": {
+        "@types/node": "*",
+        "playwright-core": "1.34.3"
+      },
+      "bin": {
+        "playwright": "cli.js"
+      },
+      "engines": {
+        "node": ">=14"
+      },
+      "optionalDependencies": {
+        "fsevents": "2.3.2"
+      }
+    },
     "node_modules/@polka/url": {
       "version": "1.0.0-next.21",
       "resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.21.tgz",
@@ -2382,6 +2415,24 @@
         "node": ">= 8.0.0"
       }
     },
+    "node_modules/@sideway/address": {
+      "version": "4.1.4",
+      "resolved": "https://registry.npmjs.org/@sideway/address/-/address-4.1.4.tgz",
+      "integrity": "sha512-7vwq+rOHVWjyXxVlR76Agnvhy8I9rpzjosTESvmhNeXOXdZZB15Fl+TI9x1SiHZH5Jv2wTGduSxFDIaq0m3DUw==",
+      "dependencies": {
+        "@hapi/hoek": "^9.0.0"
+      }
+    },
+    "node_modules/@sideway/formula": {
+      "version": "3.0.1",
+      "resolved": "https://registry.npmjs.org/@sideway/formula/-/formula-3.0.1.tgz",
+      "integrity": "sha512-/poHZJJVjx3L+zVD6g9KgHfYnb443oi7wLu/XKojDviHy6HOEOA6z1Trk5aR1dGcmPenJEgb2sK2I80LeS3MIg=="
+    },
+    "node_modules/@sideway/pinpoint": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/@sideway/pinpoint/-/pinpoint-2.0.0.tgz",
+      "integrity": "sha512-RNiOoTPkptFtSVzQevY/yWtZwf/RxyVnPy/OcA9HBM3MlGDnBEYL5B41H0MTn0Uec8Hi+2qUtTfG2WWZBmMejQ=="
+    },
     "node_modules/@toml-tools/lexer": {
       "version": "0.3.1",
       "resolved": "https://registry.npmjs.org/@toml-tools/lexer/-/lexer-0.3.1.tgz",
@@ -5353,6 +5404,18 @@
         "jiti": "bin/jiti.js"
       }
     },
+    "node_modules/joi": {
+      "version": "17.9.2",
+      "resolved": "https://registry.npmjs.org/joi/-/joi-17.9.2.tgz",
+      "integrity": "sha512-Itk/r+V4Dx0V3c7RLFdRh12IOjySm2/WGPMubBT92cQvRfYZhPM2W0hZlctjj72iES8jsRCwp7S/cRmWBnJ4nw==",
+      "dependencies": {
+        "@hapi/hoek": "^9.0.0",
+        "@hapi/topo": "^5.0.0",
+        "@sideway/address": "^4.1.3",
+        "@sideway/formula": "^3.0.1",
+        "@sideway/pinpoint": "^2.0.0"
+      }
+    },
     "node_modules/js-beautify": {
       "version": "1.14.6",
       "resolved": "https://registry.npmjs.org/js-beautify/-/js-beautify-1.14.6.tgz",
@@ -6124,6 +6187,17 @@
       "resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.0.tgz",
       "integrity": "sha512-ODbEPR0KKHqECXW1GoxdDb+AZvULmXjVPy4rt+pGo2+TnjJTIPJQSVS6N63n8T2Ip+syHhbn52OewKicV0373w=="
     },
+    "node_modules/playwright-core": {
+      "version": "1.34.3",
+      "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.34.3.tgz",
+      "integrity": "sha512-2pWd6G7OHKemc5x1r1rp8aQcpvDh7goMBZlJv6Co5vCNLVcQJdhxRL09SGaY6HcyHH9aT4tiynZabMofVasBYw==",
+      "bin": {
+        "playwright-core": "cli.js"
+      },
+      "engines": {
+        "node": ">=14"
+      }
+    },
     "node_modules/postcss": {
       "version": "8.4.23",
       "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.23.tgz",
@@ -6566,6 +6640,14 @@
         "queue-microtask": "^1.2.2"
       }
     },
+    "node_modules/rxjs": {
+      "version": "7.8.1",
+      "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.1.tgz",
+      "integrity": "sha512-AA3TVj+0A2iuIoQkWEK/tqFjBq2j+6PO6Y0zJcvzLAFhEFIO3HL0vls9hWLncZbAAbK0mar7oZ4V079I/qPMxg==",
+      "dependencies": {
+        "tslib": "^2.1.0"
+      }
+    },
     "node_modules/sade": {
       "version": "1.8.1",
       "resolved": "https://registry.npmjs.org/sade/-/sade-1.8.1.tgz",
@@ -7585,6 +7667,33 @@
         "node": ">=14"
       }
     },
+    "node_modules/wait-on": {
+      "version": "7.0.1",
+      "resolved": "https://registry.npmjs.org/wait-on/-/wait-on-7.0.1.tgz",
+      "integrity": "sha512-9AnJE9qTjRQOlTZIldAaf/da2eW0eSRSgcqq85mXQja/DW3MriHxkpODDSUEg+Gri/rKEcXUZHe+cevvYItaog==",
+      "dependencies": {
+        "axios": "^0.27.2",
+        "joi": "^17.7.0",
+        "lodash": "^4.17.21",
+        "minimist": "^1.2.7",
+        "rxjs": "^7.8.0"
+      },
+      "bin": {
+        "wait-on": "bin/wait-on"
+      },
+      "engines": {
+        "node": ">=12.0.0"
+      }
+    },
+    "node_modules/wait-on/node_modules/axios": {
+      "version": "0.27.2",
+      "resolved": "https://registry.npmjs.org/axios/-/axios-0.27.2.tgz",
+      "integrity": "sha512-t+yRIyySRTp/wua5xEr+z1q60QmLq8ABsS5O9Me1AsE5dfKqgnCFzwiCZZ/cGNd1lq4/7akDWMxdhVlucjmnOQ==",
+      "dependencies": {
+        "follow-redirects": "^1.14.9",
+        "form-data": "^4.0.0"
+      }
+    },
     "node_modules/webidl-conversions": {
       "version": "7.0.0",
       "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz",
diff --git a/package.json b/package.json
index 8f9c2698..c96eee8f 100644
--- a/package.json
+++ b/package.json
@@ -7,13 +7,15 @@
     "test": "TZ=Europe/Berlin vitest",
     "lint": "prettier assets './**/*.{yml,yaml,toml}' --write && eslint assets/**/*.js assets/**/*.vue --fix",
     "dev": "vite",
-    "histoire": "histoire dev"
+    "histoire": "histoire dev",
+    "playwright": "playwright test --ui"
   },
   "main": "index.js",
   "dependencies": {
     "@babel/core": "^7.17.2",
     "@h2d2/shopicons": "^1.2.0",
     "@histoire/plugin-vue": "^0.16.1",
+    "@playwright/test": "^1.34.3",
     "@popperjs/core": "^2.11.5",
     "@vitejs/plugin-legacy": "^4.0.2",
     "@vitejs/plugin-vue": "^4.1.0",
@@ -46,7 +48,8 @@
     "vue-hot-reload-api": "^2.3.4",
     "vue-i18n": "^9.2.2",
     "vue-meta": "^3.0.0-alpha.2",
-    "vue-router": "^4.0.12"
+    "vue-router": "^4.0.12",
+    "wait-on": "^7.0.1"
   },
   "engines": {
     "npm": ">=8.0.0",
diff --git a/playwright.config.js b/playwright.config.js
new file mode 100644
index 00000000..b005349a
--- /dev/null
+++ b/playwright.config.js
@@ -0,0 +1,23 @@
+const { defineConfig, devices } = require("@playwright/test");
+
+/**
+ * @see https://playwright.dev/docs/test-configuration
+ */
+module.exports = defineConfig({
+  testDir: "./tests",
+  fullyParallel: false,
+  forbidOnly: !!process.env.CI,
+  retries: process.env.CI ? 2 : 0,
+  workers: 1, // run testfiles serially to avoid port and database conflicts
+  reporter: "html",
+  use: {
+    baseURL: "http://127.0.0.1:7070",
+    trace: "on-first-retry",
+  },
+  projects: [
+    {
+      name: "chromium",
+      use: { ...devices["Desktop Chrome"] },
+    },
+  ],
+});
diff --git a/provider/modbus.go b/provider/modbus.go
index 56aa7ce9..6d423019 100644
--- a/provider/modbus.go
+++ b/provider/modbus.go
@@ -13,7 +13,6 @@ import (
 	"github.com/evcc-io/evcc/util/modbus"
 	gridx "github.com/grid-x/modbus"
 	"github.com/volkszaehler/mbmd/meters"
-	"github.com/volkszaehler/mbmd/meters/rs485"
 	"github.com/volkszaehler/mbmd/meters/sunspec"
 )
 
@@ -140,10 +139,12 @@ func NewModbusFromConfig(other map[string]interface{}) (Provider, error) {
 func (m *Modbus) bytesGetter() ([]byte, error) {
 	if op := m.op.MBMD; op.FuncCode != 0 {
 		switch op.FuncCode {
-		case rs485.ReadHoldingReg:
+		case gridx.FuncCodeReadHoldingRegisters:
 			return m.conn.ReadHoldingRegisters(op.OpCode, op.ReadLen)
-		case rs485.ReadInputReg:
+		case gridx.FuncCodeReadInputRegisters:
 			return m.conn.ReadInputRegisters(op.OpCode, op.ReadLen)
+		case gridx.FuncCodeReadCoils:
+			return m.conn.ReadCoils(op.OpCode, op.ReadLen)
 		default:
 			return nil, fmt.Errorf("unknown function code %d", op.FuncCode)
 		}
@@ -234,6 +235,8 @@ func (m *Modbus) StringGetter() func() (string, error) {
 // UintFromBytes converts byte slice to bigendian uint value
 func UintFromBytes(bytes []byte) (u uint64, err error) {
 	switch l := len(bytes); l {
+	case 1:
+		u = uint64(bytes[0])
 	case 2:
 		u = uint64(binary.BigEndian.Uint16(bytes))
 	case 4:
@@ -272,6 +275,13 @@ func (m *Modbus) IntSetter(param string) func(int64) error {
 			switch op.FuncCode {
 			case gridx.FuncCodeWriteSingleRegister:
 				_, err = m.conn.WriteSingleRegister(op.OpCode, uval)
+			case gridx.FuncCodeWriteSingleCoil:
+				if uval != 0 {
+					// Modbus protocol requires 0xFF00 for ON
+					// and 0x0000 for OFF
+					uval = 0xFF00
+				}
+				_, err = m.conn.WriteSingleCoil(op.OpCode, uval)
 			default:
 				err = fmt.Errorf("unknown function code %d", op.FuncCode)
 			}
diff --git a/templates/definition/meter/fox-ess-h3-ethernet.yaml b/templates/definition/meter/fox-ess-h3-ethernet.yaml
index bf86968b..88ad92b8 100644
--- a/templates/definition/meter/fox-ess-h3-ethernet.yaml
+++ b/templates/definition/meter/fox-ess-h3-ethernet.yaml
@@ -5,11 +5,45 @@ products:
       generic: H3 via Ethernet
 params:
   - name: usage
-    choice: ["pv", "battery"]
+    choice: ["grid", "pv", "battery"]
   - name: modbus
     choice: ["tcpip"]
 render: |
   type: custom
+  {{- if eq .usage "grid" }}
+  power:
+    source: calc
+    add:
+    - source: modbus
+      {{- include "modbus" . | indent 4 }}
+      register:
+        address: 31026 # Meter Power R
+        type: holding
+        decode: int16
+      scale: -1
+    - source: modbus
+      {{- include "modbus" . | indent 4 }}
+      register:
+        address: 31027 # Meter Power S
+        type: holding
+        decode: int16
+      scale: -1
+    - source: modbus
+      {{- include "modbus" . | indent 4 }}
+      register:
+        address: 31028 # Meter Power T
+        type: holding
+        decode: int16
+      scale: -1
+  energy:
+    source: modbus
+    {{- include "modbus" . | indent 2 }}
+    register:
+      address: 31012 # Grid Consumption Total
+      type: holding
+      decode: uint32
+    scale: 0.1
+  {{- end }}
   {{- if eq .usage "pv" }}
   power:
     source: calc
diff --git a/templates/definition/vehicle/iso15118.yaml b/templates/definition/vehicle/iso15118.yaml
index 16cddf1c..fcb2c289 100644
--- a/templates/definition/vehicle/iso15118.yaml
+++ b/templates/definition/vehicle/iso15118.yaml
@@ -2,6 +2,7 @@ template: iso15118
 products:
   - description:
       generic: ISO15118
+capabilities: ["iso151182"]
 group: generic
 requirements:
   description:
@@ -29,4 +30,7 @@ render: |
   {{- if .phases }}
   phases: {{ .phases }}
   {{- end }}
+  soc:
+    source: const
+    value: 0
   {{ include "vehicle-identify" . }}
diff --git a/templates/definition/vehicle/offline.yaml b/templates/definition/vehicle/offline.yaml
index 135fe4d4..31ae4e9a 100644
--- a/templates/definition/vehicle/offline.yaml
+++ b/templates/definition/vehicle/offline.yaml
@@ -20,6 +20,7 @@ params:
   - name: identifiers
     advanced: true
     type: stringlist
+  - preset: vehicle-identify
 render: |
   type: custom
   features: ["offline"]
@@ -33,15 +34,15 @@ render: |
   {{- if .phases }}
   phases: {{ .phases }}
   {{- end }}
-  soc: # fixed - no Soc available
-    source: js
-    script: 0;
+  soc:
+    source: const
+    value: 0
   onIdentify:
   {{- if .mode }}
     mode: {{ .mode }}
   {{- end }}
-    minSoc: 0 # fixed - no Soc available
-    targetSoc: 100 # fixed - no Soc available
+    minSoc: 0 # fixed - no soc available
+    targetSoc: 100 # fixed - no soc available
   {{- if .minCurrent }}
     minCurrent: {{ .minCurrent }}
   {{- end }}
diff --git a/templates/docs/meter/fox-ess-h3_0.yaml b/templates/docs/meter/fox-ess-h3_0.yaml
index 0489a9ef..1fb69220 100644
--- a/templates/docs/meter/fox-ess-h3_0.yaml
+++ b/templates/docs/meter/fox-ess-h3_0.yaml
@@ -2,6 +2,17 @@ product:
   brand: FoxESS
   description: H3 via Ethernet
 render:
+  - usage: grid
+    default: |
+      type: template
+      template: fox-ess-h3
+      usage: grid
+
+      # Modbus TCP
+      modbus: tcpip
+      id: 1
+      host: 192.0.2.2 # Hostname
+      port: 502 # Port
   - usage: pv
     default: |
       type: template
diff --git a/templates/docs/vehicle/iso15118_0.yaml b/templates/docs/vehicle/iso15118_0.yaml
index 8a3be6f4..db05912e 100644
--- a/templates/docs/vehicle/iso15118_0.yaml
+++ b/templates/docs/vehicle/iso15118_0.yaml
@@ -1,6 +1,7 @@
 product:
   description: ISO15118
   group: Generische Unterstützung
+capabilities: ["iso151182"]
 description: |
   Nur unterstützt wenn das Fahrzeug den Ladestand (Soc) an die verbundene Wallbox übermitteln kann.
 render:
diff --git a/templates/docs/vehicle/offline_0.yaml b/templates/docs/vehicle/offline_0.yaml
index 714964f2..d97bed53 100644
--- a/templates/docs/vehicle/offline_0.yaml
+++ b/templates/docs/vehicle/offline_0.yaml
@@ -18,3 +18,10 @@ render:
       minCurrent: 6 # Definiert die minimale Stromstärke pro angeschlossener Phase mit welcher das Fahrzeug geladen werden soll (Optional)
       maxCurrent: 16 # Definiert die maximale Stromstärke pro angeschlossener Phase mit welcher das Fahrzeug geladen werden soll (Optional)
       identifiers: # Kann meist erst später eingetragen werden, siehe: https://docs.evcc.io/docs/guides/vehicles/#erkennung-des-fahrzeugs-an-der-wallbox (Optional)
+      mode: # Möglich sind Off, Now, MinPV und PV, oder leer wenn keiner definiert werden soll (Optional)
+      minSoc: 25 # Ladung mit maximaler Geschwindigkeit bis zu dem angegeben Ladestand unabhängig PV-Erzeugung, wenn der Lademodus nicht auf 'Aus' steht (Optional)
+      targetSoc: 80 # Bis zu welchem Ladestand (Soc) soll das Fahrzeug geladen werden (Optional)
+      minCurrent: 6 # Definiert die minimale Stromstärke pro angeschlossener Phase mit welcher das Fahrzeug geladen werden soll (Optional)
+      maxCurrent: 16 # Definiert die maximale Stromstärke pro angeschlossener Phase mit welcher das Fahrzeug geladen werden soll (Optional)
+      identifiers: # Kann meist erst später eingetragen werden, siehe: https://docs.evcc.io/docs/guides/vehicles/#erkennung-des-fahrzeugs-an-der-wallbox (Optional)
+      priority: # Priorität des Ladepunktes oder Fahrzeugs in Relation zu anderen Ladepunkten oder Fahrzeugen für die Zuweisung von PV-Energie (Optional)
diff --git a/util/modbus/functions.go b/util/modbus/functions.go
index bcb98e89..7a733009 100644
--- a/util/modbus/functions.go
+++ b/util/modbus/functions.go
@@ -39,6 +39,15 @@ func decodeMask(mask string) (uint64, error) {
 	return strconv.ParseUint(mask, 10, 64)
 }
 
+// decodeBool8 converts a masked uint1 to a bool
+func decodeBool8(b []byte) float64 {
+	u := b[0]
+	if u > 0 {
+		return 1
+	}
+	return 0
+}
+
 // decodeBool16 converts a masked uint16 to a bool
 func decodeBool16(mask uint64) func(b []byte) float64 {
 	return func(b []byte) float64 {
diff --git a/util/modbus/modbus.go b/util/modbus/modbus.go
index 382803ac..acc91e3f 100644
--- a/util/modbus/modbus.go
+++ b/util/modbus/modbus.go
@@ -375,14 +375,25 @@ func RegisterOperation(r Register) (rs485.Operation, error) {
 		op.FuncCode = modbus.FuncCodeReadHoldingRegisters
 	case "input":
 		op.FuncCode = modbus.FuncCodeReadInputRegisters
-	case "writesingle":
+	case "coil":
+		op.FuncCode = modbus.FuncCodeReadCoils
+		r.Decode = "bool8"
+	case "writesingle", "writeholding":
 		op.FuncCode = modbus.FuncCodeWriteSingleRegister
+	case "writecoil":
+		op.FuncCode = modbus.FuncCodeWriteSingleCoil
+		r.Decode = "bool8"
 	default:
 		return rs485.Operation{}, fmt.Errorf("invalid register type: %s", r.Type)
 	}
 
 	switch strings.ToLower(r.Decode) {
 
+	// 8 bit (coil)
+	case "bool8":
+		op.Transform = decodeBool8
+		op.ReadLen = 1
+
 	// 16 bit
 	case "int16":
 		op.Transform = asFloat64(encoding.Int16)


Test patch:
diff --git a/tests/basics.evcc.yaml b/tests/basics.evcc.yaml
new file mode 100755
index 00000000..99832e63
--- /dev/null
+++ b/tests/basics.evcc.yaml
@@ -0,0 +1,34 @@
+site:
+  title: Hello World
+  meters:
+    grid: grid
+
+meters:
+  - name: grid
+    type: custom
+    power:
+      source: js
+      script: |
+        1000
+
+loadpoints:
+  - title: Carport
+    charger: charger
+
+chargers:
+  - name: charger
+    type: custom
+    enable:
+      source: js
+      script:
+    enabled:
+      source: js
+      script: |
+        false
+    status:
+      source: js
+      script: |
+        "B"
+    maxcurrent:
+      source: js
+      script:
diff --git a/tests/basics.spec.js b/tests/basics.spec.js
new file mode 100644
index 00000000..950f19c2
--- /dev/null
+++ b/tests/basics.spec.js
@@ -0,0 +1,37 @@
+const { test, expect } = require("@playwright/test");
+const { start, stop } = require("./evcc");
+
+test.beforeAll(async () => {
+  await start("basics.evcc.yaml");
+});
+test.afterAll(async () => {
+  await stop();
+});
+
+test.beforeEach(async ({ page }) => {
+  await page.goto("/");
+});
+
+test.describe("main screen", async () => {
+  test("site title", async ({ page }) => {
+    await expect(page.getByRole("heading", { name: "Hello World" })).toBeVisible();
+  });
+
+  test("visualization", async ({ page }) => {
+    const locator = page.getByTestId("visualization");
+    await expect(locator).toBeVisible();
+    await expect(locator).toContainText("1,000 W");
+  });
+
+  test("one loadpoint", async ({ page }) => {
+    await expect(page.getByTestId("loadpoint")).toHaveCount(1);
+  });
+
+  test("loadpoint title", async ({ page }) => {
+    await expect(page.getByRole("heading", { name: "Carport" })).toBeVisible();
+  });
+
+  test("guest vehicle", async ({ page }) => {
+    await expect(page.getByRole("heading", { name: "Guest vehicle" })).toBeVisible();
+  });
+});
diff --git a/tests/evcc.js b/tests/evcc.js
new file mode 100644
index 00000000..7aee9a77
--- /dev/null
+++ b/tests/evcc.js
@@ -0,0 +1,40 @@
+import fs from "fs";
+import waitOn from "wait-on";
+import axios from "axios";
+import { exec, execSync } from "child_process";
+import playwrightConfig from "../playwright.config";
+
+const BASE_URL = playwrightConfig.use.baseURL;
+
+const DB_PATH = "./evcc.db";
+const BINARY = "./evcc";
+
+export async function start(config, database) {
+  clean();
+  if (database) {
+    console.log("loading database", { database });
+    execSync(`sqlite3 ${DB_PATH} < tests/${database}`);
+  }
+  console.log("starting evcc", { config });
+  const instance = exec(`EVCC_DATABASE_DSN=${DB_PATH} ${BINARY} --config tests/${config}`);
+  instance.stdout.pipe(process.stdout);
+  instance.on("exit", (code) => {
+    if (code !== 0) {
+      throw new Error("evcc terminated", code);
+    }
+  });
+  await waitOn({ resources: [BASE_URL] });
+}
+
+export async function stop() {
+  console.log("shutting down evcc");
+  await axios.post(BASE_URL + "/api/shutdown");
+  clean();
+}
+
+function clean() {
+  if (fs.existsSync(DB_PATH)) {
+    console.log("delete database", DB_PATH);
+    fs.unlinkSync(DB_PATH);
+  }
+}
diff --git a/tests/sessions.spec.js b/tests/sessions.spec.js
new file mode 100644
index 00000000..65b01fd3
--- /dev/null
+++ b/tests/sessions.spec.js
@@ -0,0 +1,159 @@
+const { test, expect } = require("@playwright/test");
+const { start, stop } = require("./evcc");
+
+test.beforeAll(async () => {
+  await start("basics.evcc.yaml", "sessions.sql");
+});
+test.afterAll(async () => {
+  await stop();
+});
+
+test.describe("basics", async () => {
+  test("navigation to sessions", async ({ page }) => {
+    await page.goto("/");
+    const topNavigationButton = await page.getByTestId("topnavigation-button");
+    await topNavigationButton.click();
+    await page
+      .getByTestId("topnavigation-dropdown")
+      .getByRole("link", { name: "Charging sessions" })
+      .click();
+    await expect(page.getByRole("heading", { name: "Charging Sessions" })).toBeVisible();
+  });
+  test("month without data", async ({ page }) => {
+    await page.goto("/#/sessions?year=2023&month=6");
+    await expect(page.getByTestId("sessions-nodata")).toHaveCount(1);
+    await expect(page.getByTestId("sessions-entry")).toHaveCount(0);
+  });
+  test("month with data", async ({ page }) => {
+    await page.goto("/#/sessions?year=2023&month=5");
+    await expect(page.getByRole("heading", { name: "MAY 2023" })).toBeVisible();
+    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.getByText("Charged 20.0 kWh")).toBeVisible();
+    await expect(page.getByText("Solar 67.3%")).toBeVisible();
+    await expect(page.getByText("Σ Price 5.50 €")).toBeVisible();
+    await expect(page.getByText("Ø Price 27.5 ct/kWh")).toBeVisible();
+    await expect(page.getByTestId("sessions-entry")).toHaveCount(4);
+  });
+});
+
+test.describe("paging", async () => {
+  test("prev/next links", async ({ page }) => {
+    await page.goto("/#/sessions?year=2023&month=5");
+    await expect(page.getByRole("link", { name: "April" })).toBeVisible();
+    await expect(page.getByRole("link", { name: "June" })).toBeVisible();
+  });
+  test("next month", async ({ page }) => {
+    await page.goto("/#/sessions?year=2023&month=5");
+    await page.getByRole("link", { name: "June" }).click();
+    await expect(page.getByRole("heading", { name: "JUNE 2023" })).toBeVisible();
+    await expect(page.getByRole("link", { name: "May" })).toBeVisible();
+    await expect(page.getByRole("link", { name: "July" })).toBeVisible();
+  });
+  test("prev month", async ({ page }) => {
+    await page.goto("/#/sessions?year=2023&month=6");
+    await page.getByRole("link", { name: "May" }).click();
+    await expect(page.getByRole("heading", { name: "MAY 2023" })).toBeVisible();
+  });
+});
+
+test.describe("filter", async () => {
+  test("by vehicle", async ({ page }) => {
+    await page.goto("/#/sessions?year=2023&month=5");
+    await expect(page.getByTestId("sessions-entry")).toHaveCount(4);
+
+    await page.getByLabel("all vehicles").selectOption("blauer e-Golf (2)");
+    await expect(page.getByTestId("sessions-entry")).toHaveCount(2);
+    await expect(page.getByTestId("sessions-entry").nth(0)).toHaveText(/blauer e-Golf/);
+    await expect(page.getByTestId("sessions-entry").nth(1)).toHaveText(/blauer e-Golf/);
+
+    await page.getByLabel("all vehicles").selectOption("weißes Model 3 (2)");
+    await expect(page.getByTestId("sessions-entry")).toHaveCount(2);
+    await expect(page.getByTestId("sessions-entry").nth(0)).toHaveText(/weißes Model 3/);
+    await expect(page.getByTestId("sessions-entry").nth(1)).toHaveText(/weißes Model 3/);
+
+    await page.getByLabel("all vehicles").selectOption("all vehicles (4)");
+    await expect(page.getByTestId("sessions-entry")).toHaveCount(4);
+  });
+
+  test("by loadpoint", async ({ page }) => {
+    await page.goto("/#/sessions?year=2023&month=5");
+    await expect(page.getByTestId("sessions-entry")).toHaveCount(4);
+
+    await page.getByLabel("all charging points").selectOption("Carport (3)");
+    await expect(page.getByTestId("sessions-entry")).toHaveCount(3);
+    await expect(page.getByTestId("sessions-entry").nth(0)).toHaveText(/Carport/);
+    await expect(page.getByTestId("sessions-entry").nth(1)).toHaveText(/Carport/);
+    await expect(page.getByTestId("sessions-entry").nth(2)).toHaveText(/Carport/);
+
+    await page.getByLabel("all charging points").selectOption("Garage (1)");
+    await expect(page.getByTestId("sessions-entry")).toHaveCount(1);
+    await expect(page.getByTestId("sessions-entry").nth(0)).toHaveText(/Garage/);
+
+    await page.getByLabel("all charging points").selectOption("all charging points (4)");
+    await expect(page.getByTestId("sessions-entry")).toHaveCount(4);
+  });
+
+  test("by vehicle and loadpoint", async ({ page }) => {
+    await page.goto("/#/sessions?year=2023&month=5");
+
+    await page.getByLabel("all charging points").selectOption("Carport (3)");
+    await page.getByLabel("all vehicles").selectOption("weißes Model 3 (1)");
+    await expect(page.getByTestId("sessions-entry")).toHaveCount(1);
+    await expect(page.getByTestId("sessions-entry")).toHaveText(/Carport/);
+    await expect(page.getByTestId("sessions-entry")).toHaveText(/weißes Model 3/);
+
+    await page.getByLabel("all vehicles").selectOption("blauer e-Golf (2)");
+    await expect(page.getByTestId("sessions-entry")).toHaveCount(2);
+    await expect(page.getByTestId("sessions-entry").nth(0)).toHaveText(/Carport/);
+    await expect(page.getByTestId("sessions-entry").nth(0)).toHaveText(/blauer e-Golf/);
+    await expect(page.getByTestId("sessions-entry").nth(1)).toHaveText(/Carport/);
+    await expect(page.getByTestId("sessions-entry").nth(1)).toHaveText(/blauer e-Golf/);
+  });
+
+  test("by vehicle and loadpoint disabled options", async ({ page }) => {
+    await page.goto("/#/sessions?year=2023&month=5");
+
+    await page.getByLabel("all vehicles").selectOption("blauer e-Golf (2)");
+    const option = page.locator("option[value=Garage]");
+    await expect(option).toHaveAttribute("disabled", "");
+    await expect(option).toHaveText("Garage (0)");
+  });
+
+  test("keep filter when paging", async ({ page }) => {
+    await page.goto("/#/sessions?year=2023&month=5");
+
+    await page.getByLabel("all charging points").selectOption("Carport (3)");
+    await expect(page.getByTestId("sessions-entry")).toHaveCount(3);
+    await page.getByRole("link", { name: "June" }).click();
+    await page.getByRole("link", { name: "May" }).click();
+    await expect(page.getByTestId("sessions-entry")).toHaveCount(3);
+  });
+});
+
+test.describe("csv export", async () => {
+  test("total export", async ({ page }) => {
+    await page.goto("/#/sessions?year=2023&month=5");
+    await expect(page.getByRole("link", { name: "Download total CSV" })).toHaveAttribute(
+      "href",
+      "./api/sessions?format=csv&lang=en"
+    );
+
+    await page.goto("/#/sessions?year=2023&month=6");
+    await expect(page.getByRole("link", { name: "Download total CSV" })).toHaveAttribute(
+      "href",
+      "./api/sessions?format=csv&lang=en"
+    );
+  });
+  test("monthly export", async ({ page }) => {
+    await page.goto("/#/sessions?year=2023&month=5");
+    await expect(page.getByRole("link", { name: "Download May 2023 CSV" })).toHaveAttribute(
+      "href",
+      "./api/sessions?format=csv&lang=en&year=2023&month=5"
+    );
+
+    await page.goto("/#/sessions?year=2023&month=6");
+    await expect(page.getByRole("link", { name: "Download June 2023 CSV" })).toHaveCount(0);
+  });
+});
diff --git a/tests/sessions.sql b/tests/sessions.sql
new file mode 100644
index 00000000..632a01a5
--- /dev/null
+++ b/tests/sessions.sql
@@ -0,0 +1,23 @@
+DROP TABLE IF EXISTS `sessions`;
+CREATE TABLE `sessions` (
+  `id` integer,
+  `created` datetime,
+  `finished` datetime,
+  `loadpoint` text,
+  `identifier` text,
+  `vehicle` text,
+  `meter_start_kwh` real,
+  `meter_end_kwh` real,
+  `charged_kwh` real,
+  `odometer` real,
+  `solar_percentage` real,
+  `price` real, 
+  `price_per_kwh` real,
+  `co2_per_kwh` real,
+  PRIMARY KEY (`id`)
+);
+
+INSERT INTO `sessions` VALUES (1,'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 (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,2.5,NULL,88.21,0.75,0.3,NULL);
+INSERT INTO `sessions` VALUES (3,'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 (4,'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