Repository: evcc-io/evcc
Pull request #13801 (2024-05-07 18:14:39)

Frauenhofer ISE energy-charts-api | masked &end parameter for runtime readout
> läuft nun.. aber noch nicht getestet, ob das "verstecken" der Klammern notwendig ist.
> 
> `{{"{{"}} now {{"}}"}}`

> Doch, müssen sein- sonst würden die Funktionen direkt beim Template einlesen ausgeführt anstatt erst zur Laufzeit der Abfrage. Läuft bei mir auch- sehr schön 👍🏻

War nur noch nicht eingebaut ;) - wollte es ja noch testen.. mit "Maskierung" funktioniert es aber weiterhin.

`&end={{"{{"}} now | dateModify "+24h" | date "2006-01-02" {{"}}"}}`


Patch:
diff --git a/.github/workflows/default.yml b/.github/workflows/default.yml
index 95dcaa5a..de7f2441 100644
--- a/.github/workflows/default.yml
+++ b/.github/workflows/default.yml
@@ -177,11 +177,6 @@ jobs:
   integration:
     name: Integration
     runs-on: ubuntu-latest
-    strategy:
-      fail-fast: false
-      matrix:
-        shardIndex: [1, 2]
-        shardTotal: [2]
 
     steps:
       - uses: actions/checkout@v4
@@ -224,7 +219,7 @@ jobs:
         run: npx playwright install --with-deps chromium
 
       - name: Run tests
-        run: npx playwright test --shard=${{ matrix.shardIndex }}/${{ matrix.shardTotal }}
+        run: npx playwright test
 
       # - name: Run tests
       #   uses: docker://mcr.microsoft.com/playwright:v1.34.3-jammy
@@ -234,6 +229,6 @@ jobs:
       - uses: actions/upload-artifact@v4
         if: ${{ !cancelled() }}
         with:
-          name: playwright-report-${{ matrix.shardIndex }}
+          name: playwright-report
           path: playwright-report/
           retention-days: 14
diff --git a/core/loadpoint.go b/core/loadpoint.go
index 200caf8a..a19366c7 100644
--- a/core/loadpoint.go
+++ b/core/loadpoint.go
@@ -1416,6 +1416,9 @@ func (lp *Loadpoint) publishChargeProgress() {
 		// workaround for Go-E resetting during disconnect, see
 		// https://github.com/evcc-io/evcc/issues/5092
 		if f > lp.chargedAtStartup {
+			{ // TODO remove
+				lp.log.DEBUG.Printf("!! session: chargeRater.chargedEnergy=%.1f - chargedAtStartup=%.1f", f, lp.chargedAtStartup)
+			}
 			added, addedGreen := lp.sessionEnergy.Update(f - lp.chargedAtStartup)
 			if telemetry.Enabled() && added > 0 {
 				telemetry.UpdateEnergy(added, addedGreen)
diff --git a/core/loadpoint_session.go b/core/loadpoint_session.go
index 008ef92d..f72b2f09 100644
--- a/core/loadpoint_session.go
+++ b/core/loadpoint_session.go
@@ -64,7 +64,7 @@ func (lp *Loadpoint) stopSession() {
 
 	if chargedEnergy := lp.getChargedEnergy() / 1e3; chargedEnergy > s.ChargedEnergy {
 		{ // TODO remove
-			lp.log.DEBUG.Printf("session: chargedEnergy=%.1f", s.ChargedEnergy)
+			lp.log.DEBUG.Printf("!! session: chargedEnergy=%.1f > chargedEnergy=%.1f", chargedEnergy, s.ChargedEnergy)
 		}
 		lp.sessionEnergy.Update(chargedEnergy)
 	}
@@ -85,7 +85,7 @@ func (lp *Loadpoint) stopSession() {
 		if s.MeterStop != nil {
 			meterStop = *s.MeterStop
 		}
-		lp.log.DEBUG.Printf("session: start=%.3f stop=%.3f chargedEnergy=%.3f", meterStart, meterStop, s.ChargedEnergy)
+		lp.log.DEBUG.Printf("!! session: start=%.3f stop=%.3f chargedEnergy=%.3f", meterStart, meterStop, s.ChargedEnergy)
 	}
 
 	lp.db.Persist(s)
diff --git a/main.go b/main.go
index 60f47f19..49005972 100644
--- a/main.go
+++ b/main.go
@@ -12,10 +12,10 @@ import (
 )
 
 var (
-	// go:embed dist
+	//go:embed dist
 	web embed.FS
 
-	// go:embed i18n/*.toml
+	//go:embed i18n/*.toml
 	i18n embed.FS
 )
 
diff --git a/playwright.config.js b/playwright.config.js
index 17932c99..c10ac944 100644
--- a/playwright.config.js
+++ b/playwright.config.js
@@ -4,22 +4,21 @@ import { defineConfig, devices } from "@playwright/test";
  * @see https://playwright.dev/docs/test-configuration
  */
 export default defineConfig({
-	testDir: "./tests",
-	fullyParallel: false,
-	forbidOnly: !!process.env.CI,
-	retries: process.env.CI ? 2 : 0,
-	timeout: 15000, // 15s (default 30s)
-	expect: { timeout: 2500 }, // 2.5s (default 5s)
-	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"] },
-		},
-	],
+  testDir: "./tests",
+  forbidOnly: !!process.env.CI,
+  retries: process.env.CI ? 2 : 0,
+  timeout: 15000, // 15s (default 30s)
+  expect: { timeout: 2500 }, // 2.5s (default 5s)
+  workers: process.env.CI ? 3 : 8,
+  reporter: "html",
+  use: {
+    baseURL: "http://127.0.0.1:7070",
+    trace: "on-first-retry",
+  },
+  projects: [
+    {
+      name: "chromium",
+      use: { ...devices["Desktop Chrome"] },
+    },
+  ],
 });
diff --git a/tariff/octopus.go b/tariff/octopus.go
index 1a496573..eb69484a 100644
--- a/tariff/octopus.go
+++ b/tariff/octopus.go
@@ -48,7 +48,7 @@ func NewOctopusFromConfig(other map[string]interface{}) (api.Tariff, error) {
 		if cc.Region == "" {
 			return nil, errors.New("missing region")
 		}
-		if cc.Tariff == "" {
+		if cc.Tariff != "" {
 			// deprecated - copy to correct slot and WARN
 			logger.WARN.Print("'tariff' is deprecated and will break in a future version - use 'productCode' instead")
 			cc.ProductCode = cc.Tariff
diff --git a/templates/definition/tariff/energy-charts-api.yaml b/templates/definition/tariff/energy-charts-api.yaml
index 0935da2d..0dc46413 100644
--- a/templates/definition/tariff/energy-charts-api.yaml
+++ b/templates/definition/tariff/energy-charts-api.yaml
@@ -37,5 +37,5 @@ render: |
   {{ include "tariff-base" . }}
   forecast:
     source: http
-    uri: https://api.energy-charts.info/price?bzn={{ .bzn }}&end={{ now | dateModify "+24h" | date "2006-01-02" }}
+    uri: https://api.energy-charts.info/price?bzn={{ .bzn }}&end={{"{{"}} now | dateModify "+24h" | date "2006-01-02" {{"}}"}}
     jq: '[.unix_seconds, .price] | transpose | map({ "start": (.[0] | todate), "end": ((.[0]+3600) | todate), "price": (.[1]/1000)}) | tostring'
diff --git a/templates/definition/vehicle/teslalogger.yaml b/templates/definition/vehicle/teslalogger.yaml
index f1e65b89..691f0615 100644
--- a/templates/definition/vehicle/teslalogger.yaml
+++ b/templates/definition/vehicle/teslalogger.yaml
@@ -78,7 +78,7 @@ render: |
     uri: {{ .url }}:{{ .port }}/command/{{ .id }}/wake_up
   chargeEnable:
     source: http
-    uri: {{ .url }}:{{ .port }}/command/{{ .id }}/charge_start_stop?${enable}
+    uri: {{ .url }}:{{ .port }}/command/{{ .id }}/charge_start_stop?${chargeenable}
   maxcurrent:
     source: http
     uri: {{ .url }}:{{ .port }}/command/{{ .id }}/set_charging_amps?${maxcurrent}
diff --git a/util/format.go b/util/format.go
index a92dfa90..8f1cb5dc 100644
--- a/util/format.go
+++ b/util/format.go
@@ -11,7 +11,7 @@ import (
 	"github.com/42atomys/sprout"
 )
 
-var re = regexp.MustCompile(`\${(\w+)(:([a-zA-Z0-9%.]+))?}`)
+var re = regexp.MustCompile(`(?i)\${(\w+)(:([a-zA-Z0-9%.]+))?}`)
 
 // Truish returns true if value is truish (true/1/on)
 func Truish(s string) bool {
@@ -73,7 +73,7 @@ func ReplaceFormatted(s string, kv map[string]interface{}) (string, error) {
 		match, key, format := m[0], m[1], m[3]
 
 		// find key and replacement value
-		val, ok := kv[key]
+		val, ok := kv[strings.ToLower(key)]
 		if !ok {
 			wanted = append(wanted, key)
 			format = "%s"
diff --git a/vehicle/vehicle.go b/vehicle/vehicle.go
index 6c56fd40..4f7a2d85 100644
--- a/vehicle/vehicle.go
+++ b/vehicle/vehicle.go
@@ -107,7 +107,7 @@ func NewConfigurableFromConfig(other map[string]interface{}) (api.Vehicle, error
 	// decorate maxCurrent
 	var maxCurrent func(int64) error
 	if cc.MaxCurrent != nil {
-		maxCurrent, err = provider.NewIntSetterFromConfig("maxCurrent", *cc.MaxCurrent)
+		maxCurrent, err = provider.NewIntSetterFromConfig("maxcurrent", *cc.MaxCurrent)
 		if err != nil {
 			return nil, fmt.Errorf("maxCurrent: %w", err)
 		}
@@ -153,7 +153,7 @@ func NewConfigurableFromConfig(other map[string]interface{}) (api.Vehicle, error
 	// decorate chargeEnable
 	var chargeEnable func(bool) error
 	if cc.ChargeEnable != nil {
-		chargeEnable, err = provider.NewBoolSetterFromConfig("chargeEnable", *cc.ChargeEnable)
+		chargeEnable, err = provider.NewBoolSetterFromConfig("chargeenable", *cc.ChargeEnable)
 		if err != nil {
 			return nil, fmt.Errorf("chargeEnable: %w", err)
 		}


Test patch:
diff --git a/tariff/octopus_test.go b/tariff/octopus_test.go
new file mode 100644
index 00000000..30a4f524
--- /dev/null
+++ b/tariff/octopus_test.go
@@ -0,0 +1,34 @@
+package tariff
+
+import (
+	"testing"
+
+	"github.com/stretchr/testify/require"
+)
+
+func TestOctopusConfigParse(t *testing.T) {
+	// This test will start failing if you remove the deprecated "tariff" config var.
+	validTariffConfig := map[string]interface{}{
+		"region": "H",
+		"tariff": "GO-22-03-29",
+	}
+
+	_, err := NewOctopusFromConfig(validTariffConfig)
+	require.NoError(t, err)
+
+	validProductCodeConfig := map[string]interface{}{
+		"region":      "H",
+		"productcode": "GO-22-03-29",
+	}
+
+	_, err = NewOctopusFromConfig(validProductCodeConfig)
+	require.NoError(t, err)
+
+	invalidApiAndProductCodeConfig := map[string]interface{}{
+		"region":      "H",
+		"productcode": "GO-22-03-29",
+		"apikey":      "nope",
+	}
+	_, err = NewOctopusFromConfig(invalidApiAndProductCodeConfig)
+	require.Error(t, err)
+}
diff --git a/tests/auth.spec.js b/tests/auth.spec.js
index 23620d8f..e7163cff 100644
--- a/tests/auth.spec.js
+++ b/tests/auth.spec.js
@@ -1,5 +1,7 @@
 import { test, expect } from "@playwright/test";
-import { start, stop } from "./evcc";
+import { start, stop, baseUrl } from "./evcc";
+
+test.use({ baseURL: baseUrl() });
 
 test.beforeEach(async ({ page }) => {
   await start("basics.evcc.yaml");
diff --git a/tests/basics.spec.js b/tests/basics.spec.js
index 490d7c79..4a850414 100644
--- a/tests/basics.spec.js
+++ b/tests/basics.spec.js
@@ -1,5 +1,7 @@
 import { test, expect } from "@playwright/test";
-import { start, stop } from "./evcc";
+import { start, stop, baseUrl } from "./evcc";
+
+test.use({ baseURL: baseUrl() });
 
 test.beforeAll(async () => {
   await start("basics.evcc.yaml", "password.sql");
diff --git a/tests/config-meters.spec.js b/tests/config-meters.spec.js
new file mode 100644
index 00000000..fa382342
--- /dev/null
+++ b/tests/config-meters.spec.js
@@ -0,0 +1,85 @@
+import { test, expect } from "@playwright/test";
+import { start, stop, restart, baseUrl } from "./evcc";
+import { startSimulator, stopSimulator, simulatorUrl, simulatorHost } from "./simulator";
+
+const CONFIG_EMPTY = "config-empty.evcc.yaml";
+
+test.use({ baseURL: baseUrl() });
+
+test.beforeAll(async () => {
+  await start(CONFIG_EMPTY, "password.sql");
+  await startSimulator();
+});
+test.afterAll(async () => {
+  await stop();
+  await stopSimulator();
+});
+
+async function login(page) {
+  await page.locator("#loginPassword").fill("secret");
+  await page.getByRole("button", { name: "Login" }).click();
+}
+
+async function enableExperimental(page) {
+  await page
+    .getByTestId("generalconfig-experimental")
+    .getByRole("link", { name: "change" })
+    .click();
+  await page.getByLabel("Experimental 🧪").click();
+  await page.getByRole("button", { name: "Close" }).click();
+}
+
+test.describe("meters", async () => {
+  test("create, edit and remove battery meter", async ({ page }) => {
+    // setup test data for mock openems api
+    await page.goto(simulatorUrl());
+    await page.getByLabel("Battery Power").fill("-2500");
+    await page.getByLabel("Battery SoC").fill("75");
+    await page.getByRole("button", { name: "Apply changes" }).click();
+
+    await page.goto("/#/config");
+    await login(page);
+    await enableExperimental(page);
+
+    await expect(page.getByTestId("battery")).toHaveCount(0);
+
+    // create #1
+    await page.getByRole("button", { name: "Add solar or battery" }).click();
+
+    const meterModal = page.getByTestId("meter-modal");
+    await meterModal.getByRole("button", { name: "Add battery meter" }).click();
+    await meterModal.getByLabel("Manufacturer").selectOption("OpenEMS");
+    await meterModal.getByLabel("IP address or hostname").fill(simulatorHost());
+    await expect(meterModal.getByRole("button", { name: "Validate & save" })).toBeVisible();
+    await meterModal.getByRole("link", { name: "validate" }).click();
+    await expect(meterModal.getByText("SoC: 75.0%")).toBeVisible();
+    await expect(meterModal.getByText("Power: -2.5 kW")).toBeVisible();
+    await meterModal.getByRole("button", { name: "Save" }).click();
+    await expect(page.getByTestId("battery")).toBeVisible(1);
+    await expect(page.getByTestId("battery")).toContainText("openems");
+
+    // edit #1
+    await page.getByTestId("battery").getByRole("button", { name: "edit" }).click();
+    await meterModal.getByLabel("Battery capacity in kWh").fill("20");
+    await meterModal.getByRole("button", { name: "Validate & save" }).click();
+
+    await expect(page.getByTestId("battery")).toBeVisible(1);
+    await expect(page.getByTestId("battery")).toContainText("openems");
+    await expect(page.getByTestId("battery").getByText("SoC: 75.0%")).toBeVisible();
+    await expect(page.getByTestId("battery").getByText("Power: -2.5 kW")).toBeVisible();
+    await expect(page.getByTestId("battery").getByText("Capacity: 20.0 kWh")).toBeVisible();
+
+    // restart and check in main ui
+    await restart(CONFIG_EMPTY);
+    await page.goto("/");
+    await page.getByTestId("visualization").click();
+    await expect(page.getByTestId("energyflow")).toContainText("Battery charging75%2.5 kW");
+
+    // delete #1
+    await page.goto("/#/config");
+    await page.getByTestId("battery").getByRole("button", { name: "edit" }).click();
+    await meterModal.getByRole("button", { name: "Delete" }).click();
+
+    await expect(page.getByTestId("battery")).toHaveCount(0);
+  });
+});
diff --git a/tests/config-vehicles.spec.js b/tests/config-vehicles.spec.js
new file mode 100644
index 00000000..5e7e4c5e
--- /dev/null
+++ b/tests/config-vehicles.spec.js
@@ -0,0 +1,132 @@
+import { test, expect } from "@playwright/test";
+import { start, stop, restart, cleanRestart, baseUrl } from "./evcc";
+
+const CONFIG_EMPTY = "config-empty.evcc.yaml";
+const CONFIG_WITH_VEHICLE = "config-with-vehicle.evcc.yaml";
+
+test.use({ baseURL: baseUrl() });
+
+test.beforeAll(async () => {
+  await start(CONFIG_EMPTY, "password.sql");
+});
+test.afterAll(async () => {
+  await stop();
+});
+
+async function login(page) {
+  await page.locator("#loginPassword").fill("secret");
+  await page.getByRole("button", { name: "Login" }).click();
+}
+
+async function enableExperimental(page) {
+  await page
+    .getByTestId("generalconfig-experimental")
+    .getByRole("link", { name: "change" })
+    .click();
+  await page.getByLabel("Experimental 🧪").click();
+  await page.getByRole("button", { name: "Close" }).click();
+}
+
+test.describe("vehicles", async () => {
+  test("create, edit and delete vehicles", async ({ page }) => {
+    await page.goto("/#/config");
+    await login(page);
+    await enableExperimental(page);
+
+    await expect(page.getByTestId("vehicle")).toHaveCount(0);
+    const vehicleModal = page.getByTestId("vehicle-modal");
+
+    // create #1
+    await page.getByTestId("add-vehicle").click();
+    await vehicleModal.getByLabel("Manufacturer").selectOption("Generic vehicle");
+    await vehicleModal.getByLabel("Title").fill("Green Car");
+    await vehicleModal.getByRole("button", { name: "Validate & save" }).click();
+
+    await expect(page.getByTestId("vehicle")).toHaveCount(1);
+
+    // create #2
+    await page.getByTestId("add-vehicle").click();
+    await vehicleModal.getByLabel("Manufacturer").selectOption("Generic vehicle");
+    await vehicleModal.getByLabel("Title").fill("Yellow Van");
+    await vehicleModal.getByRole("button", { name: "Validate & save" }).click();
+
+    await expect(page.getByTestId("vehicle")).toHaveCount(2);
+    await expect(page.getByTestId("vehicle").nth(0)).toHaveText(/Green Car/);
+    await expect(page.getByTestId("vehicle").nth(1)).toHaveText(/Yellow Van/);
+
+    // edit #1
+    await page.getByTestId("vehicle").nth(0).getByRole("button", { name: "edit" }).click();
+    await expect(vehicleModal.getByLabel("Title")).toHaveValue("Green Car");
+    await vehicleModal.getByLabel("Title").fill("Fancy Car");
+    await vehicleModal.getByRole("button", { name: "Validate & save" }).click();
+
+    await expect(page.getByTestId("vehicle")).toHaveCount(2);
+    await expect(page.getByTestId("vehicle").nth(0)).toHaveText(/Fancy Car/);
+
+    // delete #1
+    await page.getByTestId("vehicle").nth(0).getByRole("button", { name: "edit" }).click();
+    await vehicleModal.getByRole("button", { name: "Delete" }).click();
+
+    await expect(page.getByTestId("vehicle")).toHaveCount(1);
+    await expect(page.getByTestId("vehicle").nth(0)).toHaveText(/Yellow Van/);
+
+    // delete #2
+    await page.getByTestId("vehicle").nth(0).getByRole("button", { name: "edit" }).click();
+    await vehicleModal.getByRole("button", { name: "Delete" }).click();
+
+    await expect(page.getByTestId("vehicle")).toHaveCount(0);
+  });
+
+  test("config should survive restart", async ({ page }) => {
+    await page.goto("/#/config");
+    await login(page);
+    await enableExperimental(page);
+
+    await expect(page.getByTestId("vehicle")).toHaveCount(0);
+    const vehicleModal = page.getByTestId("vehicle-modal");
+
+    // create #1 & #2
+    await page.getByTestId("add-vehicle").click();
+    await vehicleModal.getByLabel("Manufacturer").selectOption("Generic vehicle");
+    await vehicleModal.getByLabel("Title").fill("Green Car");
+    await vehicleModal.getByRole("button", { name: "Validate & save" }).click();
+
+    await page.getByTestId("add-vehicle").click();
+    await vehicleModal.getByLabel("Manufacturer").selectOption("Generic vehicle");
+    await vehicleModal.getByLabel("Title").fill("Yellow Van");
+    await vehicleModal.getByLabel("car").click();
+    await vehicleModal.getByLabel("van").check();
+    await vehicleModal.getByRole("button", { name: "Validate & save" }).click();
+
+    await expect(page.getByTestId("vehicle")).toHaveCount(2);
+
+    // restart evcc
+    await restart(CONFIG_EMPTY);
+    await page.reload();
+
+    await expect(page.getByTestId("vehicle")).toHaveCount(2);
+    await expect(page.getByTestId("vehicle").nth(0)).toHaveText(/Green Car/);
+    await expect(page.getByTestId("vehicle").nth(1)).toHaveText(/Yellow Van/);
+  });
+
+  test("mixed config (yaml + db)", async ({ page }) => {
+    await cleanRestart(CONFIG_WITH_VEHICLE, "password.sql");
+
+    await page.goto("/#/config");
+    await login(page);
+    await enableExperimental(page);
+
+    await expect(page.getByTestId("vehicle")).toHaveCount(1);
+    const vehicleModal = page.getByTestId("vehicle-modal");
+
+    // create #1
+    await page.getByTestId("add-vehicle").click();
+    await vehicleModal.getByLabel("Manufacturer").selectOption("Generic vehicle");
+    await vehicleModal.getByLabel("Title").fill("Green Car");
+    await vehicleModal.getByRole("button", { name: "Validate & save" }).click();
+
+    await expect(page.getByTestId("vehicle")).toHaveCount(2);
+    await expect(page.getByTestId("vehicle").nth(0)).toHaveText(/YAML Bike/);
+    await expect(page.getByTestId("vehicle").nth(1)).toHaveText(/Green Car/);
+  });
+});
diff --git a/tests/config.spec.js b/tests/config.spec.js
index 6edeccb0..15a9da81 100644
--- a/tests/config.spec.js
+++ b/tests/config.spec.js
@@ -1,9 +1,9 @@
 import { test, expect } from "@playwright/test";
-import { start, stop, restart, cleanRestart } from "./evcc";
-import { startSimulator, stopSimulator, SIMULATOR_URL, SIMULATOR_HOST } from "./simulator";
+import { start, stop, baseUrl } from "./evcc";
 
 const CONFIG_EMPTY = "config-empty.evcc.yaml";
-const CONFIG_WITH_VEHICLE = "config-with-vehicle.evcc.yaml";
+
+test.use({ baseURL: baseUrl() });
 
 test.beforeAll(async () => {
   await start(CONFIG_EMPTY, "password.sql");
@@ -42,172 +42,6 @@ test.describe("basics", async () => {
   });
 });
 
-test.describe("vehicles", async () => {
-  test("create, edit and delete vehicles", async ({ page }) => {
-    await page.goto("/#/config");
-    await login(page);
-    await enableExperimental(page);
-
-    await expect(page.getByTestId("vehicle")).toHaveCount(0);
-    const vehicleModal = page.getByTestId("vehicle-modal");
-
-    // create #1
-    await page.getByTestId("add-vehicle").click();
-    await vehicleModal.getByLabel("Manufacturer").selectOption("Generic vehicle");
-    await vehicleModal.getByLabel("Title").fill("Green Car");
-    await vehicleModal.getByRole("button", { name: "Validate & save" }).click();
-
-    await expect(page.getByTestId("vehicle")).toHaveCount(1);
-
-    // create #2
-    await page.getByTestId("add-vehicle").click();
-    await vehicleModal.getByLabel("Manufacturer").selectOption("Generic vehicle");
-    await vehicleModal.getByLabel("Title").fill("Yellow Van");
-    await vehicleModal.getByRole("button", { name: "Validate & save" }).click();
-
-    await expect(page.getByTestId("vehicle")).toHaveCount(2);
-    await expect(page.getByTestId("vehicle").nth(0)).toHaveText(/Green Car/);
-    await expect(page.getByTestId("vehicle").nth(1)).toHaveText(/Yellow Van/);
-
-    // edit #1
-    await page.getByTestId("vehicle").nth(0).getByRole("button", { name: "edit" }).click();
-    await expect(vehicleModal.getByLabel("Title")).toHaveValue("Green Car");
-    await vehicleModal.getByLabel("Title").fill("Fancy Car");
-    await vehicleModal.getByRole("button", { name: "Validate & save" }).click();
-
-    await expect(page.getByTestId("vehicle")).toHaveCount(2);
-    await expect(page.getByTestId("vehicle").nth(0)).toHaveText(/Fancy Car/);
-
-    // delete #1
-    await page.getByTestId("vehicle").nth(0).getByRole("button", { name: "edit" }).click();
-    await vehicleModal.getByRole("button", { name: "Delete" }).click();
-
-    await expect(page.getByTestId("vehicle")).toHaveCount(1);
-    await expect(page.getByTestId("vehicle").nth(0)).toHaveText(/Yellow Van/);
-
-    // delete #2
-    await page.getByTestId("vehicle").nth(0).getByRole("button", { name: "edit" }).click();
-    await vehicleModal.getByRole("button", { name: "Delete" }).click();
-
-    await expect(page.getByTestId("vehicle")).toHaveCount(0);
-  });
-
-  test("config should survive restart", async ({ page }) => {
-    await page.goto("/#/config");
-    await login(page);
-    await enableExperimental(page);
-
-    await expect(page.getByTestId("vehicle")).toHaveCount(0);
-    const vehicleModal = page.getByTestId("vehicle-modal");
-
-    // create #1 & #2
-    await page.getByTestId("add-vehicle").click();
-    await vehicleModal.getByLabel("Manufacturer").selectOption("Generic vehicle");
-    await vehicleModal.getByLabel("Title").fill("Green Car");
-    await vehicleModal.getByRole("button", { name: "Validate & save" }).click();
-
-    await page.getByTestId("add-vehicle").click();
-    await vehicleModal.getByLabel("Manufacturer").selectOption("Generic vehicle");
-    await vehicleModal.getByLabel("Title").fill("Yellow Van");
-    await vehicleModal.getByLabel("car").click();
-    await vehicleModal.getByLabel("van").check();
-    await vehicleModal.getByRole("button", { name: "Validate & save" }).click();
-
-    await expect(page.getByTestId("vehicle")).toHaveCount(2);
-
-    // restart evcc
-    await restart(CONFIG_EMPTY);
-    await page.reload();
-
-    await expect(page.getByTestId("vehicle")).toHaveCount(2);
-    await expect(page.getByTestId("vehicle").nth(0)).toHaveText(/Green Car/);
-    await expect(page.getByTestId("vehicle").nth(1)).toHaveText(/Yellow Van/);
-  });
-
-  test("mixed config (yaml + db)", async ({ page }) => {
-    await cleanRestart(CONFIG_WITH_VEHICLE, "password.sql");
-
-    await page.goto("/#/config");
-    await login(page);
-    await enableExperimental(page);
-
-    await expect(page.getByTestId("vehicle")).toHaveCount(1);
-    const vehicleModal = page.getByTestId("vehicle-modal");
-
-    // create #1
-    await page.getByTestId("add-vehicle").click();
-    await vehicleModal.getByLabel("Manufacturer").selectOption("Generic vehicle");
-    await vehicleModal.getByLabel("Title").fill("Green Car");
-    await vehicleModal.getByRole("button", { name: "Validate & save" }).click();
-
-    await expect(page.getByTestId("vehicle")).toHaveCount(2);
-    await expect(page.getByTestId("vehicle").nth(0)).toHaveText(/YAML Bike/);
-    await expect(page.getByTestId("vehicle").nth(1)).toHaveText(/Green Car/);
-  });
-});
-
-test.describe("meters", async () => {
-  test.beforeAll(async () => {
-    await startSimulator();
-  });
-  test.afterAll(async () => {
-    await stopSimulator();
-  });
-
-  test("create, edit and remove battery meter", async ({ page }) => {
-    // setup test data for mock openems api
-    await page.goto(SIMULATOR_URL);
-    await page.getByLabel("Battery Power").fill("-2500");
-    await page.getByLabel("Battery SoC").fill("75");
-    await page.getByRole("button", { name: "Apply changes" }).click();
-
-    await page.goto("/#/config");
-    await login(page);
-    await enableExperimental(page);
-
-    await expect(page.getByTestId("battery")).toHaveCount(0);
-
-    // create #1
-    await page.getByRole("button", { name: "Add solar or battery" }).click();
-
-    const meterModal = page.getByTestId("meter-modal");
-    await meterModal.getByRole("button", { name: "Add battery meter" }).click();
-    await meterModal.getByLabel("Manufacturer").selectOption("OpenEMS");
-    await meterModal.getByLabel("IP address or hostname").fill(SIMULATOR_HOST);
-    await expect(meterModal.getByRole("button", { name: "Validate & save" })).toBeVisible();
-    await meterModal.getByRole("link", { name: "validate" }).click();
-    await expect(meterModal.getByText("SoC: 75.0%")).toBeVisible();
-    await expect(meterModal.getByText("Power: -2.5 kW")).toBeVisible();
-    await meterModal.getByRole("button", { name: "Save" }).click();
-    await expect(page.getByTestId("battery")).toBeVisible(1);
-    await expect(page.getByTestId("battery")).toContainText("openems");
-
-    // edit #1
-    await page.getByTestId("battery").getByRole("button", { name: "edit" }).click();
-    await meterModal.getByLabel("Battery capacity in kWh").fill("20");
-    await meterModal.getByRole("button", { name: "Validate & save" }).click();
-
-    await expect(page.getByTestId("battery")).toBeVisible(1);
-    await expect(page.getByTestId("battery")).toContainText("openems");
-    await expect(page.getByTestId("battery").getByText("SoC: 75.0%")).toBeVisible();
-    await expect(page.getByTestId("battery").getByText("Power: -2.5 kW")).toBeVisible();
-    await expect(page.getByTestId("battery").getByText("Capacity: 20.0 kWh")).toBeVisible();
-
-    // restart and check in main ui
-    await restart(CONFIG_EMPTY);
-    await page.goto("/");
-    await page.getByTestId("visualization").click();
-    await expect(page.getByTestId("energyflow")).toContainText("Battery charging75%2.5 kW");
-
-    // delete #1
-    await page.goto("/#/config");
-    await page.getByTestId("battery").getByRole("button", { name: "edit" }).click();
-    await meterModal.getByRole("button", { name: "Delete" }).click();
-
-    await expect(page.getByTestId("battery")).toHaveCount(0);
-  });
-});
-
 test.describe("general", async () => {
   test("change site title", async ({ page }) => {
     // initial value on main ui
diff --git a/tests/evcc.js b/tests/evcc.js
index 906c3006..b8ef6dd4 100644
--- a/tests/evcc.js
+++ b/tests/evcc.js
@@ -2,13 +2,25 @@ import fs from "fs";
 import waitOn from "wait-on";
 import axios from "axios";
 import { exec, execSync } from "child_process";
-import playwrightConfig from "../playwright.config.js";
+import os from "os";
+import path from "path";
 
-const BASE_URL = playwrightConfig.use.baseURL;
-
-const DB_PATH = "./evcc.db";
 const BINARY = "./evcc";
 
+function port() {
+  const index = process.env.TEST_WORKER_INDEX * 1;
+  return 11000 + index;
+}
+
+export function baseUrl() {
+  return `http://localhost:${port()}`;
+}
+
+function dbPath() {
+  const file = `evcc-${port()}.db`;
+  return path.join(os.tmpdir(), file);
+}
+
 export async function start(config, sqlDumps) {
   await _clean();
   if (sqlDumps) {
@@ -39,14 +51,17 @@ export async function cleanRestart(config, sqlDumps) {
 async function _restoreDatabase(sqlDumps) {
   const dumps = Array.isArray(sqlDumps) ? sqlDumps : [sqlDumps];
   for (const dump of dumps) {
-    console.log("loading database", dump);
-    execSync(`sqlite3 ${DB_PATH} < tests/${dump}`);
+    console.log("loading database", dbPath(), dump);
+    execSync(`sqlite3 ${dbPath()} < tests/${dump}`);
   }
 }
 
 async function _start(config) {
+  const configFile = config.includes("/") ? config : `tests/${config}`;
   console.log("starting evcc", { config });
-  const instance = exec(`EVCC_DATABASE_DSN=${DB_PATH} ${BINARY} --config tests/${config}`);
+  const instance = exec(
+    `EVCC_NETWORK_PORT=${port()} EVCC_DATABASE_DSN=${dbPath()} ${BINARY} --config ${configFile}`
+  );
   instance.stdout.pipe(process.stdout);
   instance.stderr.pipe(process.stderr);
   instance.on("exit", (code) => {
@@ -54,20 +69,21 @@ async function _start(config) {
       throw new Error("evcc terminated", code);
     }
   });
-  await waitOn({ resources: [BASE_URL] });
+  await waitOn({ resources: [baseUrl()] });
 }
 
 async function _stop() {
   console.log("shutting down evcc");
-  const res = await axios.post(BASE_URL + "/api/auth/login", { password: "secret" });
+  const res = await axios.post(`${baseUrl()}/api/auth/login`, { password: "secret" });
   console.log(res.status, res.statusText);
   const cookie = res.headers["set-cookie"];
-  await axios.post(BASE_URL + "/api/system/shutdown", {}, { headers: { cookie } });
+  await axios.post(`${baseUrl()}/api/system/shutdown`, {}, { headers: { cookie } });
 }
 
 async function _clean() {
-  if (fs.existsSync(DB_PATH)) {
-    console.log("delete database", DB_PATH);
-    fs.unlinkSync(DB_PATH);
+  const db = dbPath();
+  if (fs.existsSync(db)) {
+    console.log("delete database", db);
+    fs.unlinkSync(db);
   }
 }
diff --git a/tests/heating.spec.js b/tests/heating.spec.js
index 4cafc832..d11df196 100644
--- a/tests/heating.spec.js
+++ b/tests/heating.spec.js
@@ -1,5 +1,7 @@
 import { test, expect } from "@playwright/test";
-import { start, stop } from "./evcc";
+import { start, stop, baseUrl } from "./evcc";
+
+test.use({ baseURL: baseUrl() });
 
 test.beforeAll(async () => {
   await start("heating.evcc.yaml", "password.sql");
diff --git a/tests/limits.spec.js b/tests/limits.spec.js
index e7244f71..520cfcb3 100644
--- a/tests/limits.spec.js
+++ b/tests/limits.spec.js
@@ -1,8 +1,8 @@
 import { test, expect } from "@playwright/test";
-import { start, stop } from "./evcc";
-import { startSimulator, stopSimulator, SIMULATOR_URL } from "./simulator";
+import { start, stop, baseUrl } from "./evcc";
+import { startSimulator, stopSimulator, simulatorUrl, simulatorConfig } from "./simulator";
 
-const CONFIG = "simulator.evcc.yaml";
+test.use({ baseURL: baseUrl() });
 
 test.beforeAll(async () => {
   await startSimulator();
@@ -12,9 +12,9 @@ test.afterAll(async () => {
 });
 
 test.beforeEach(async ({ page }) => {
-  await start(CONFIG, "password.sql");
+  await start(simulatorConfig(), "password.sql");
 
-  await page.goto(SIMULATOR_URL);
+  await page.goto(simulatorUrl());
   await page.getByLabel("Grid Power").fill("500");
   await page.getByTestId("vehicle0").getByLabel("SoC").fill("20");
   await page.getByTestId("loadpoint0").getByText("B (connected)").click();
@@ -39,7 +39,7 @@ test.describe("limitSoc", async () => {
   });
 
   test("can be set even if vehicle isn't connected yet", async ({ page }) => {
-    await page.goto(SIMULATOR_URL);
+    await page.goto(simulatorUrl());
     await page.getByTestId("loadpoint0").getByText("A (disconnected)").click();
     await page.getByRole("button", { name: "Apply changes" }).click();
 
@@ -50,7 +50,7 @@ test.describe("limitSoc", async () => {
     await page.getByTestId("limit-soc").getByRole("combobox").selectOption("50%");
     await expect(page.getByTestId("limit-soc-value")).toHaveText("50%");
 
-    await page.goto(SIMULATOR_URL);
+    await page.goto(simulatorUrl());
     await page.getByTestId("loadpoint0").getByText("B (connected)").click();
     await page.getByRole("button", { name: "Apply changes" }).click();
 
@@ -65,7 +65,7 @@ test.describe("limitSoc", async () => {
     await expect(page.getByTestId("limit-soc-value")).toHaveText("50%");
 
     // disconnect
-    await page.goto(SIMULATOR_URL);
+    await page.goto(simulatorUrl());
     await page.getByTestId("loadpoint0").getByText("A (disconnected)").click();
     await page.getByRole("button", { name: "Apply changes" }).click();
 
@@ -74,7 +74,7 @@ test.describe("limitSoc", async () => {
     await expect(page.getByTestId("limit-soc-value")).toHaveText("100%");
 
     // connect
-    await page.goto(SIMULATOR_URL);
+    await page.goto(simulatorUrl());
     await page.getByTestId("loadpoint0").getByText("B (connected)").click();
     await page.getByRole("button", { name: "Apply changes" }).click();
 
diff --git a/tests/logs.spec.js b/tests/logs.spec.js
index 91bd0d90..867ec493 100644
--- a/tests/logs.spec.js
+++ b/tests/logs.spec.js
@@ -1,5 +1,7 @@
 import { test, expect } from "@playwright/test";
-import { start, stop } from "./evcc";
+import { start, stop, baseUrl } from "./evcc";
+
+test.use({ baseURL: baseUrl() });
 
 test.beforeAll(async () => {
   await start("basics.evcc.yaml", "password.sql");
diff --git a/tests/modals.spec.js b/tests/modals.spec.js
index 62f22932..7d6fe553 100644
--- a/tests/modals.spec.js
+++ b/tests/modals.spec.js
@@ -1,12 +1,13 @@
 import { test, expect } from "@playwright/test";
-import { start, stop } from "./evcc";
-import { startSimulator, stopSimulator } from "./simulator";
+import { start, stop, baseUrl } from "./evcc";
+import { startSimulator, stopSimulator, simulatorConfig } from "./simulator";
 
 const BASICS_CONFIG = "basics.evcc.yaml";
-const SIMULATOR_CONFIG = "simulator.evcc.yaml";
 
 const UI_ROUTES = ["/", "/#/sessions", "/#/config"];
 
+test.use({ baseURL: baseUrl() });
+
 async function login(page) {
   await page.locator("#loginPassword").fill("secret");
   await page.getByRole("button", { name: "Login" }).click();
@@ -55,7 +56,7 @@ test.describe("Basics", async () => {
 
 test.describe("Advanced", async () => {
   test.beforeAll(async () => {
-    await start(SIMULATOR_CONFIG, "password.sql");
+    await start(simulatorConfig(), "password.sql");
     await startSimulator();
   });
 
diff --git a/tests/plan.spec.js b/tests/plan.spec.js
index db0e0ce7..2836339b 100644
--- a/tests/plan.spec.js
+++ b/tests/plan.spec.js
@@ -1,5 +1,7 @@
 import { test, expect } from "@playwright/test";
-import { start, stop } from "./evcc";
+import { start, stop, baseUrl } from "./evcc";
+
+test.use({ baseURL: baseUrl() });
 
 const CONFIG = "plan.evcc.yaml";
 
diff --git a/tests/sessions.spec.js b/tests/sessions.spec.js
index 20f98bb2..e46c6d6b 100644
--- a/tests/sessions.spec.js
+++ b/tests/sessions.spec.js
@@ -1,5 +1,7 @@
 import { test, expect, devices } from "@playwright/test";
-import { start, stop } from "./evcc";
+import { start, stop, baseUrl } from "./evcc";
+
+test.use({ baseURL: baseUrl() });
 
 const mobile = devices["iPhone 12 Mini"].viewport;
 const desktop = devices["Desktop Chrome"].viewport;
diff --git a/tests/simulator.js b/tests/simulator.js
index 27d91e2c..8e8ad7b8 100644
--- a/tests/simulator.js
+++ b/tests/simulator.js
@@ -1,15 +1,36 @@
+import os from "os";
+import path from "path";
+import fs from "fs";
 import waitOn from "wait-on";
 import axios from "axios";
 import { exec } from "child_process";
 
-export const SIMULATOR_HOST = "localhost:7072";
-export const SIMULATOR_URL = `http://${SIMULATOR_HOST}/`;
-const HEALTH_URL = SIMULATOR_URL + "api/state";
-const SHUTDOWN_URL = SIMULATOR_URL + "api/shutdown";
+function port() {
+  const index = process.env.TEST_PARALLEL_INDEX * 1;
+  return 12000 + index;
+}
+
+export function simulatorHost() {
+  return `localhost:${port()}`;
+}
+
+export function simulatorUrl() {
+  return `http://${simulatorHost()}`;
+}
+
+export function simulatorConfig() {
+  const input = "./tests/simulator.evcc.yaml";
+  const content = fs.readFileSync(input, "utf8");
+  const result = content.replace(/localhost:7072/g, simulatorHost());
+  const resultName = "simulator.evcc.generated.yaml";
+  const resultPath = path.join(os.tmpdir(), resultName);
+  fs.writeFileSync(resultPath, result);
+  return resultPath;
+}
 
 export async function startSimulator() {
   console.log("starting simulator");
-  const instance = exec("npm run simulator");
+  const instance = exec(`npm run simulator -- --port ${port()}`);
   console.log("exec end");
   instance.stdout.pipe(process.stdout);
   instance.stderr.pipe(process.stderr);
@@ -20,10 +41,10 @@ export async function startSimulator() {
     }
   });
   console.log("waiton");
-  await waitOn({ resources: [HEALTH_URL], log: true });
+  await waitOn({ resources: [`${simulatorUrl()}/api/state`], log: true });
 }
 
 export async function stopSimulator() {
   console.log("shutting down simulator");
-  await axios.post(SHUTDOWN_URL);
+  await axios.post(`${simulatorUrl()}/api/shutdown`);
 }
diff --git a/tests/smart-cost.spec.js b/tests/smart-cost.spec.js
index dc2dd845..8e62838d 100644
--- a/tests/smart-cost.spec.js
+++ b/tests/smart-cost.spec.js
@@ -1,11 +1,11 @@
 import { test, expect } from "@playwright/test";
-import { start, stop } from "./evcc";
-import { startSimulator, stopSimulator, SIMULATOR_URL } from "./simulator";
+import { start, stop, baseUrl } from "./evcc";
+import { startSimulator, stopSimulator, simulatorUrl, simulatorConfig } from "./simulator";
 
-const CONFIG = "simulator.evcc.yaml";
+test.use({ baseURL: baseUrl() });
 
 test.beforeAll(async () => {
-  await start(CONFIG, "password.sql");
+  await start(simulatorConfig(), "password.sql");
   await startSimulator();
 });
 test.afterAll(async () => {
@@ -14,7 +14,7 @@ test.afterAll(async () => {
 });
 
 test.beforeEach(async ({ page }) => {
-  await page.goto(SIMULATOR_URL);
+  await page.goto(simulatorUrl());
   await page.getByLabel("PV Power").fill("6000");
   await page.getByTestId("loadpoint0").getByLabel("Power").fill("6000");
   await page.getByTestId("loadpoint0").getByText("C (charging)").click();
diff --git a/tests/statistics.spec.js b/tests/statistics.spec.js
index 24951750..33d63a6d 100644
--- a/tests/statistics.spec.js
+++ b/tests/statistics.spec.js
@@ -1,5 +1,7 @@
 import { test, expect } from "@playwright/test";
-import { start, stop } from "./evcc";
+import { start, stop, baseUrl } from "./evcc";
+
+test.use({ baseURL: baseUrl() });
 
 test.beforeAll(async () => {
   await start("statistics.evcc.yaml", ["password.sql", "statistics.sql"]);
diff --git a/tests/vehicle-error.spec.js b/tests/vehicle-error.spec.js
index 381655f7..9a58a80a 100644
--- a/tests/vehicle-error.spec.js
+++ b/tests/vehicle-error.spec.js
@@ -1,5 +1,7 @@
 import { test, expect } from "@playwright/test";
-import { start, stop } from "./evcc";
+import { start, stop, baseUrl } from "./evcc";
+
+test.use({ baseURL: baseUrl() });
 
 test.beforeAll(async () => {
   await start("vehicle-error.evcc.yaml", "password.sql");
diff --git a/tests/vehicle-settings.spec.js b/tests/vehicle-settings.spec.js
index a02f8375..52adfb7f 100644
--- a/tests/vehicle-settings.spec.js
+++ b/tests/vehicle-settings.spec.js
@@ -1,8 +1,8 @@
 import { test, expect } from "@playwright/test";
-import { start, stop, restart } from "./evcc";
-import { startSimulator, stopSimulator, SIMULATOR_URL } from "./simulator";
+import { start, stop, restart, baseUrl } from "./evcc";
+import { startSimulator, stopSimulator, simulatorUrl, simulatorConfig } from "./simulator";
 
-const CONFIG = "simulator.evcc.yaml";
+test.use({ baseURL: baseUrl() });
 
 test.beforeAll(async () => {
   await startSimulator();
@@ -12,9 +12,9 @@ test.afterAll(async () => {
 });
 
 test.beforeEach(async ({ page }) => {
-  await start(CONFIG, "password.sql");
+  await start(simulatorConfig(), "password.sql");
 
-  await page.goto(SIMULATOR_URL);
+  await page.goto(simulatorUrl());
   await page.getByLabel("Grid Power").fill("500");
   await page.getByTestId("vehicle0").getByLabel("SoC").fill("20");
   await page.getByTestId("loadpoint0").getByText("B (connected)").click();
@@ -36,7 +36,7 @@ test.describe("minSoc", async () => {
     await page.getByRole("combobox", { name: "Min. charge %" }).selectOption("20%");
     await expect(page.getByText("charged to 20% in solar mode")).toBeVisible();
 
-    await restart(CONFIG);
+    await restart(simulatorConfig());
     await page.reload();
 
     await page.getByTestId("charging-plan").getByRole("button", { name: "none" }).click();
@@ -75,7 +75,7 @@ test.describe("limitSoc", async () => {
     await page.getByRole("button", { name: "Close" }).click();
     await expect(page.getByTestId("limit-soc-value")).toContainText("80%");
 
-    await restart(CONFIG);
+    await restart(simulatorConfig());
     await page.reload();
 
     await expect(page.getByTestId("limit-soc-value")).toContainText("80%");
diff --git a/util/format_test.go b/util/format_test.go
index 7cc23bcd..da21fdcd 100644
--- a/util/format_test.go
+++ b/util/format_test.go
@@ -4,6 +4,9 @@ import (
 	"math"
 	"testing"
 	"time"
+
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
 )
 
 func TestTruish(t *testing.T) {
@@ -21,10 +24,7 @@ func TestTruish(t *testing.T) {
 	}
 
 	for _, c := range cases {
-		b := Truish(c.k)
-		if b != c.v {
-			t.Errorf("expected %v got %v", c.v, b)
-		}
+		assert.Equal(t, c.v, Truish(c.k))
 	}
 }
 
@@ -36,6 +36,7 @@ func TestReplace(t *testing.T) {
 	}{
 		// regex tests
 		{"foo", true, "${foo}", "true"},
+		{"foo", true, "${Foo}", "true"},
 		{"foo", "1", "abc${foo}${foo}", "abc11"},
 		{"foo", math.Pi, "${foo:%.2f}", "3.14"},
 		{"foo", math.Pi, "${foo:%.0f}%", "3%"},
@@ -47,9 +48,8 @@ func TestReplace(t *testing.T) {
 			c.k: c.v,
 		})
 
-		if s != c.expected || err != nil {
-			t.Error(s, err)
-		}
+		require.NoError(t, err)
+		assert.Equal(t, c.expected, s)
 	}
 }