From e30102270cb92b745c2bb540c1d36accb6eb7013 Mon Sep 17 00:00:00 2001 From: Michael Geers Date: Sat, 31 May 2025 10:18:31 +0200 Subject: [PATCH] Config UI: make circuit/vehicle refs changeable (#21539) --- assets/js/components/Config/CircuitsModal.vue | 1 + cmd/setup.go | 2 +- cmd/setup_circuits_test.go | 3 +- i18n/de.json | 1 + i18n/en.json | 1 + server/http_config_loadpoint_handler.go | 2 +- tests/config-circuit.evcc.yaml | 30 +++ tests/config-circuit.spec.js | 175 ++++++++++++++++++ 8 files changed, 211 insertions(+), 4 deletions(-) create mode 100755 tests/config-circuit.evcc.yaml create mode 100644 tests/config-circuit.spec.js diff --git a/assets/js/components/Config/CircuitsModal.vue b/assets/js/components/Config/CircuitsModal.vue index 9c5e6bdf3..325da546d 100644 --- a/assets/js/components/Config/CircuitsModal.vue +++ b/assets/js/components/Config/CircuitsModal.vue @@ -7,6 +7,7 @@ :defaultYaml="defaultYaml" removeKey="circuits" endpoint="/config/circuits" + data-testid="circuits-modal" @changed="$emit('changed')" /> diff --git a/cmd/setup.go b/cmd/setup.go index 2c3563515..fcb2f961c 100644 --- a/cmd/setup.go +++ b/cmd/setup.go @@ -1007,7 +1007,7 @@ CONTINUE: } if !isRoot && !instance.HasMeter() { - return fmt.Errorf("circuit %s has no meter and no loadpoint assigned", dev.Config().Name) + log.INFO.Printf("circuit %s has no meter and no loadpoint assigned", dev.Config().Name) } } diff --git a/cmd/setup_circuits_test.go b/cmd/setup_circuits_test.go index 146efb0c3..d12029e9a 100644 --- a/cmd/setup_circuits_test.go +++ b/cmd/setup_circuits_test.go @@ -102,8 +102,7 @@ loadpoints: // circuit without device err = validateCircuits(lps) - suite.Require().Error(err) - suite.Require().Equal("circuit slave has no meter and no loadpoint assigned", err.Error()) + suite.Require().NoError(err) } func (suite *circuitsTestSuite) TestMissingRootCircuit() { diff --git a/i18n/de.json b/i18n/de.json index cfa8d354d..651485363 100644 --- a/i18n/de.json +++ b/i18n/de.json @@ -193,6 +193,7 @@ "chargerPowerCustomHelp": "Definiere einen eigenen Strombereich.", "chargerTypeLabel": "Ladetyp", "chargingTitle": "Laden", + "circuitHelp": "Lastmanagement-Zuordnung, um die Leistungs- und Stromgrenzen nicht zu überschreiten.", "circuitLabel": "Stromkreis", "circuitUnassigned": "nicht zugewiesen", "defaultModeHelp": "Lademodus beim Anschließen des Fahrzeugs.", diff --git a/i18n/en.json b/i18n/en.json index 68c0fce9a..890b79e28 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -193,6 +193,7 @@ "chargerPowerCustomHelp": "Define a custom current range.", "chargerTypeLabel": "Charger type", "chargingTitle": "Charging", + "circuitHelp": "Load management assignment to ensure power and current limits are not exceeded.", "circuitLabel": "Circuit", "circuitUnassigned": "unassigned", "defaultModeHelp": "Charging mode when connecting the vehicle.", diff --git a/server/http_config_loadpoint_handler.go b/server/http_config_loadpoint_handler.go index 81858545d..5957b495d 100644 --- a/server/http_config_loadpoint_handler.go +++ b/server/http_config_loadpoint_handler.go @@ -206,7 +206,7 @@ func updateLoadpointHandler() http.HandlerFunc { // merge here to maintain dynamic part of the config other := configurable.Config().Other - if err := mergo.Merge(&other, static); err != nil { + if err := mergo.Merge(&other, static, mergo.WithOverride); err != nil { jsonError(w, http.StatusBadRequest, err) return } diff --git a/tests/config-circuit.evcc.yaml b/tests/config-circuit.evcc.yaml new file mode 100755 index 000000000..9794559d5 --- /dev/null +++ b/tests/config-circuit.evcc.yaml @@ -0,0 +1,30 @@ +site: + meters: + grid: grid + +meters: + - name: grid + type: template + template: demo-meter + power: 2070 + currentL1: 3 + currentL2: 3 + currentL3: 3 + +loadpoints: + - title: Carport + charger: charger + circuit: main + +circuits: + - name: main + meter: grid + maxcurrent: 16 + +chargers: + - name: charger + type: template + template: demo-charger + status: C + enabled: true + power: 1000 diff --git a/tests/config-circuit.spec.js b/tests/config-circuit.spec.js new file mode 100644 index 000000000..4d57588e7 --- /dev/null +++ b/tests/config-circuit.spec.js @@ -0,0 +1,175 @@ +import { test, expect } from "@playwright/test"; +import { start, stop, restart, baseUrl } from "./evcc"; +import { + enableExperimental, + expectModalVisible, + expectModalHidden, + editorClear, + editorType, +} from "./utils"; + +const CONFIG_YAML = "config-circuit.evcc.yaml"; +const CONFIG_EMPTY = "config-empty.evcc.yaml"; + +test.use({ baseURL: baseUrl() }); + +test.afterEach(async () => { + await stop(); +}); + +test.describe("circuit", async () => { + test("from yaml", async ({ page }) => { + await start(CONFIG_YAML); + + await page.goto("/#/config"); + await enableExperimental(page); + + await expect(page.getByTestId("loadpoint")).toHaveCount(1); + await expect(page.getByTestId("loadpoint")).toContainText(["Power", "1.0 kW"].join("")); + + await expect(page.getByTestId("grid")).toHaveCount(1); + await expect(page.getByTestId("grid")).toContainText(["Power", "2.1 kW"].join("")); + await expect(page.getByTestId("grid")).toContainText( + ["Current L1, L2, L3", "3.0 · 3.0 · 3.0 A"].join("") + ); + + await expect(page.getByTestId("circuits")).toHaveCount(1); + await expect(page.getByTestId("circuits")).toContainText(["Power", "2.1 kW"].join("")); + await expect(page.getByTestId("circuits")).toContainText( + ["Current", "3.0 A / 16.0 A"].join("") + ); + }); + + test("via ui", async ({ page }) => { + await start(CONFIG_EMPTY); + + await page.goto("/#/config"); + await enableExperimental(page); + + // add grid meter + await page.getByRole("button", { name: "Add grid meter" }).click(); + const meterModal = page.getByTestId("meter-modal"); + await expectModalVisible(meterModal); + await meterModal.getByLabel("Manufacturer").selectOption("Demo meter"); + await meterModal.getByLabel("Power").fill("2070"); + await page.getByRole("button", { name: "Show advanced settings" }).click(); + await meterModal.getByLabel("L1 current").fill("3"); + await meterModal.getByLabel("L2 current").fill("3"); + await meterModal.getByLabel("L3 current").fill("3"); + await meterModal.getByRole("button", { name: "Save" }).click(); + await expectModalHidden(meterModal); + + // add loadpoint and charger + const lpModal = page.getByTestId("loadpoint-modal"); + await page.getByRole("button", { name: "Add charge point" }).click(); + await expectModalVisible(lpModal); + await lpModal.getByLabel("Title").fill("Carport"); + + // add charger + await lpModal.getByRole("button", { name: "Add charger" }).click(); + const chargerModal = page.getByTestId("charger-modal"); + await expectModalVisible(chargerModal); + await chargerModal.getByLabel("Manufacturer").selectOption("Demo charger"); + await chargerModal.getByLabel("Charge status").selectOption("C"); + await chargerModal.getByLabel("Power").fill("1000"); + await chargerModal.getByRole("button", { name: "Save" }).click(); + await expectModalHidden(chargerModal); + await expectModalVisible(lpModal); + + await lpModal.getByRole("button", { name: "Save" }).click(); + await expectModalHidden(lpModal); + + // add circuit via ui as yaml input + await page.getByTestId("circuits").getByRole("button", { name: "edit" }).click(); + const circuitsModal = page.getByTestId("circuits-modal"); + await expectModalVisible(circuitsModal); + + const editor = circuitsModal.getByTestId("yaml-editor"); + await editorClear(editor); + await editorType(editor, [ + // prettier-ignore + "- name: main", + " meter: db:1", + "maxcurrent: 16", + "Shift+Tab", + "- name: house", + " title: House", + "maxcurrent: 10", + "parent: main", + "Shift+Tab", + "- name: garage", + " title: Garage", + "maxcurrent: 8", + "parent: main", + ]); + + await circuitsModal.getByRole("button", { name: "Save" }).click(); + await expectModalHidden(circuitsModal); + + // restart + const restartButton = await page + .getByTestId("bottom-banner") + .getByRole("button", { name: "Restart" }); + await expect(restartButton).toBeVisible(); + await restart(CONFIG_EMPTY); + await page.reload(); + + // assign loadpoint to circuit + await page.getByTestId("loadpoint").getByRole("button", { name: "edit" }).click(); + await expectModalVisible(lpModal); + await lpModal.getByLabel("Circuit").selectOption("Garage [garage]"); + await lpModal.getByLabel("Circuit").selectOption("House [house]"); + await lpModal.getByRole("button", { name: "Save" }).click(); + await expectModalHidden(lpModal); + + // save, restart and check values + await expect(restartButton).toBeVisible(); + await restart(CONFIG_EMPTY); + await page.reload(); + + // verify the configuration matches the yaml test + await expect(page.getByTestId("loadpoint")).toHaveCount(1); + await expect(page.getByTestId("loadpoint")).toContainText(["Power", "1.0 kW"].join("")); + + await expect(page.getByTestId("grid")).toHaveCount(1); + await expect(page.getByTestId("grid")).toContainText(["Power", "2.1 kW"].join("")); + await expect(page.getByTestId("grid")).toContainText( + ["Current L1, L2, L3", "3.0 · 3.0 · 3.0 A"].join("") + ); + + await expect(page.getByTestId("circuits")).toHaveCount(1); + await expect(page.getByTestId("circuits")).toContainText( + ["(main)", "Power", "2.1 kW", "Current", "3.0 A / 16.0 A"].join("") + ); + await expect(page.getByTestId("circuits")).toContainText( + ["House (house)", "Power", "1.0 kW", "Current", "6.0 A / 10.0 A"].join("") + ); + await expect(page.getByTestId("circuits")).toContainText( + ["Garage (garage)", "Power", "0.0 kW", "Current", "0.0 A / 8.0 A"].join("") + ); + + // assign to garage + await page.getByTestId("loadpoint").getByRole("button", { name: "edit" }).click(); + await expectModalVisible(lpModal); + await lpModal.getByLabel("Circuit").selectOption("Garage [garage]"); + await lpModal.getByRole("button", { name: "Save" }).click(); + await expectModalHidden(lpModal); + + // save, restart and check values + await expect(restartButton).toBeVisible(); + await restart(CONFIG_EMPTY); + await page.reload(); + + // verify circuits + await expect(page.getByTestId("circuits")).toHaveCount(1); + await expect(page.getByTestId("circuits")).toContainText( + ["(main)", "Power", "2.1 kW", "Current", "3.0 A / 16.0 A"].join("") + ); + await expect(page.getByTestId("circuits")).toContainText( + ["House (house)", "Power", "0.0 kW", "Current", "0.0 A / 10.0 A"].join("") + ); + await expect(page.getByTestId("circuits")).toContainText( + ["Garage (garage)", "Power", "1.0 kW", "Current", "6.0 A / 8.0 A"].join("") + ); + }); +});