From eed0649eaa52fdb87d0c2012868966813b5defea Mon Sep 17 00:00:00 2001 From: andig Date: Fri, 19 Jun 2026 18:25:59 +0200 Subject: [PATCH] Loadpoint: adapt UI for integrated and switch devices (#30909) Co-authored-by: Michael Geers --- assets/js/components/Config/DeviceTags.vue | 3 ++ .../js/components/Config/LoadpointModal.vue | 28 ++++++++----- .../defaultYaml/switchsocketCharger.yaml | 1 + .../defaultYaml/switchsocketHeater.yaml | 1 + assets/js/components/Loadpoints/Loadpoint.vue | 4 ++ assets/js/components/Loadpoints/Mode.vue | 3 +- .../components/Loadpoints/SettingsModal.vue | 5 ++- i18n/de.json | 1 + i18n/en.json | 1 + server/http_config_helper.go | 4 ++ tests/config-loadpoint.spec.ts | 39 +++++++++++++++++++ tests/heating.evcc.yaml | 2 +- tests/heating.spec.ts | 21 ++++++++++ 13 files changed, 101 insertions(+), 12 deletions(-) diff --git a/assets/js/components/Config/DeviceTags.vue b/assets/js/components/Config/DeviceTags.vue index 7ceabf59f..40f6b09ba 100644 --- a/assets/js/components/Config/DeviceTags.vue +++ b/assets/js/components/Config/DeviceTags.vue @@ -220,6 +220,9 @@ export default { return `${this.fmtNumber(distanceValue(value), 0)} ${distanceUnit()}`; case "chargeStatus": return value ? this.$t(`config.deviceValue.chargeStatus${value}`) : "-"; + case "switchDevice": + // switch device means no current control + return this.$t(`config.deviceValue.${value ? "no" : "yes"}`); case "price": case "gridPrice": case "feedinPrice": diff --git a/assets/js/components/Config/LoadpointModal.vue b/assets/js/components/Config/LoadpointModal.vue index 0d4e3cfd2..96bd3af05 100644 --- a/assets/js/components/Config/LoadpointModal.vue +++ b/assets/js/components/Config/LoadpointModal.vue @@ -114,13 +114,7 @@ v-model="values.defaultMode" type="Choice" class="w-100" - :choice="[ - { key: '', name: '---' }, - { key: 'off', name: $t('main.mode.off') }, - { key: 'pv', name: $t('main.mode.pv') }, - { key: 'minpv', name: $t('main.mode.minpv') }, - { key: 'now', name: $t('main.mode.now') }, - ]" + :choice="defaultModeOptions" /> @@ -306,7 +300,7 @@ /> -
+
{{ $t("config.loadpoint.electricalTitle") }} {{ $t("config.loadpoint.electricalSubtitle") @@ -314,6 +308,7 @@
+
({ key, name: this.$t(`main.mode.${key}`) })); + }, showCircuit() { return this.circuits.length > 0 || !!this.values.circuit; }, diff --git a/assets/js/components/Config/defaultYaml/switchsocketCharger.yaml b/assets/js/components/Config/defaultYaml/switchsocketCharger.yaml index 7b24dfa22..29669429a 100644 --- a/assets/js/components/Config/defaultYaml/switchsocketCharger.yaml +++ b/assets/js/components/Config/defaultYaml/switchsocketCharger.yaml @@ -12,6 +12,7 @@ power: # charge power reading in W standbypower: 20 # in W, below values will be treaded as inactive features: + - switchdevice # on/off only, no current control - integrateddevice # no charging sessions, no connected vehicles diff --git a/assets/js/components/Config/defaultYaml/switchsocketHeater.yaml b/assets/js/components/Config/defaultYaml/switchsocketHeater.yaml index 5bb60110f..e682d5524 100644 --- a/assets/js/components/Config/defaultYaml/switchsocketHeater.yaml +++ b/assets/js/components/Config/defaultYaml/switchsocketHeater.yaml @@ -12,6 +12,7 @@ power: # charge power reading in W standbypower: 20 # in W, below values will be treaded as inactive features: + - switchdevice # on/off only, no current control - heating # treat as a heating device - integrateddevice # no charging sessions, no connected vehicles diff --git a/assets/js/components/Loadpoints/Loadpoint.vue b/assets/js/components/Loadpoints/Loadpoint.vue index a5a24b0e6..663f5f6ce 100644 --- a/assets/js/components/Loadpoints/Loadpoint.vue +++ b/assets/js/components/Loadpoints/Loadpoint.vue @@ -167,6 +167,7 @@ export default defineComponent({ chargerFeatureIntegratedDevice: Boolean, chargerFeatureHeating: Boolean, chargerFeatureContinuous: Boolean, + chargerFeatureSwitchDevice: Boolean, chargerIcon: String as PropType, // heating display range (ui-only) @@ -281,6 +282,9 @@ export default defineComponent({ continuous() { return this.chargerFeatureContinuous; }, + switchDevice() { + return this.chargerFeatureSwitchDevice; + }, phasesProps() { return this.collectProps(Phases); }, diff --git a/assets/js/components/Loadpoints/Mode.vue b/assets/js/components/Loadpoints/Mode.vue index fd9884966..4921f45a8 100644 --- a/assets/js/components/Loadpoints/Mode.vue +++ b/assets/js/components/Loadpoints/Mode.vue @@ -26,13 +26,14 @@ export default defineComponent({ mode: String, pvPossible: Boolean, smartCostAvailable: Boolean, + switchDevice: Boolean, }, emits: ["updated"], computed: { modes(): CHARGE_MODE[] { if (this.pvPossible) { - return [OFF, PV, MINPV, NOW]; + return this.switchDevice ? [OFF, PV, NOW] : [OFF, PV, MINPV, NOW]; } if (this.smartCostAvailable) { return [OFF, PV, NOW]; diff --git a/assets/js/components/Loadpoints/SettingsModal.vue b/assets/js/components/Loadpoints/SettingsModal.vue index 82e2e3640..cc441bd50 100644 --- a/assets/js/components/Loadpoints/SettingsModal.vue +++ b/assets/js/components/Loadpoints/SettingsModal.vue @@ -81,7 +81,7 @@
-
+
@@ -194,6 +194,9 @@ export default defineComponent({ minCurrent() { return this.loadpoint?.minCurrent; }, + switchDevice() { + return this.loadpoint?.chargerFeatureSwitchDevice; + }, batteryBoostLimit() { return this.loadpoint?.batteryBoostLimit; }, diff --git a/i18n/de.json b/i18n/de.json index 9a54df23b..3f45afffb 100644 --- a/i18n/de.json +++ b/i18n/de.json @@ -193,6 +193,7 @@ "singlePhase": "Einphasig", "soc": "Ladestand", "solarForecast": "PV-Vorhersage", + "switchDevice": "Stromregelung", "temp": "Temperatur", "topic": "Thema", "url": "URL", diff --git a/i18n/en.json b/i18n/en.json index 806c9deeb..2bd7b8b15 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -193,6 +193,7 @@ "singlePhase": "Single phase", "soc": "Charge", "solarForecast": "Solar forecast", + "switchDevice": "Current control", "temp": "Temperature", "topic": "Topic", "url": "URL", diff --git a/server/http_config_helper.go b/server/http_config_helper.go index df62e1791..3ace73443 100644 --- a/server/http_config_helper.go +++ b/server/http_config_helper.go @@ -349,6 +349,10 @@ func testInstance(instance any) map[string]testResult { makeResult("integratedDevice", true, nil) } + if hasFeature(instance, api.SwitchDevice) { + makeResult("switchDevice", true, nil) + } + if dev, ok := api.Cap[api.IconDescriber](instance); ok && dev.Icon() != "" { makeResult("icon", dev.Icon(), nil) } diff --git a/tests/config-loadpoint.spec.ts b/tests/config-loadpoint.spec.ts index 548575445..3bf55a0af 100644 --- a/tests/config-loadpoint.spec.ts +++ b/tests/config-loadpoint.spec.ts @@ -508,6 +508,45 @@ power: await expect(chargerEditor).toContainText("value: 'C'"); await expect(chargerEditor).toContainText("value: 11000"); }); + + test("user-defined switch socket (pool pump)", async ({ page }) => { + await start(); + await page.goto("/#/config"); + + // add loadpoint + await newLoadpoint(page, "Pool pump"); + const lpModal = page.getByTestId("loadpoint-modal"); + await lpModal.getByRole("button", { name: "Add charger" }).click(); + + const chargerModal = page.getByTestId("charger-modal"); + await expectModalVisible(chargerModal); + await chargerModal.getByLabel("Manufacturer").selectOption("User-defined switch socket"); + await page.waitForLoadState("networkidle"); + await expect(chargerModal.getByTestId("yaml-editor")).toContainText("switchdevice"); + + const result = chargerModal.getByTestId("test-result"); + await result.getByRole("link", { name: "validate" }).click(); + await expect(result).toContainText("Status: successful"); + await expect(result).toContainText(["Power", "11.0 kW"].join("")); + await expect(result).toContainText(["Current control", "no"].join("")); + + await chargerModal.getByRole("button", { name: "Save" }).click(); + await expectModalHidden(chargerModal); + await expectModalVisible(lpModal); + + const modeSelect = lpModal.getByLabel("Default mode"); + await expect(modeSelect.getByRole("option", { name: "Solar", exact: true })).toHaveCount(1); + await expect(modeSelect.getByRole("option", { name: "Min+Solar" })).toHaveCount(0); + + await expect(lpModal.getByText("Electrics")).toHaveCount(0); + await expect(lpModal.getByText("Charger type", { exact: true })).toHaveCount(0); + + // create + await lpModal.getByRole("button", { name: "Save" }).click(); + await expectModalHidden(lpModal); + await expect(page.getByTestId("loadpoint")).toHaveCount(1); + await expect(page.getByTestId("loadpoint")).toContainText("Pool pump"); + }); }); test.describe("heating loadpoint", async () => { diff --git a/tests/heating.evcc.yaml b/tests/heating.evcc.yaml index 65cce86ad..83b6ef4c0 100644 --- a/tests/heating.evcc.yaml +++ b/tests/heating.evcc.yaml @@ -48,4 +48,4 @@ chargers: source: js script: | 55 - features: [integrateddevice, heating] + features: [integrateddevice, heating, switchdevice] diff --git a/tests/heating.spec.ts b/tests/heating.spec.ts index 468b23d2e..da6e513d6 100644 --- a/tests/heating.spec.ts +++ b/tests/heating.spec.ts @@ -1,5 +1,6 @@ import { test, expect } from "@playwright/test"; import { start, stop, baseUrl } from "./evcc"; +import { expectModalVisible } from "./utils"; test.use({ baseURL: baseUrl() }); @@ -27,3 +28,23 @@ test.describe("loadpoint", async () => { await expect(page.getByTestId("limit-soc")).toContainText("69.0°C"); }); }); + +test.describe("integrated device", async () => { + test("no Min+Solar mode for switch device", async ({ page }) => { + const lp = page.getByTestId("loadpoint").first(); + const mode = lp.getByTestId("mode"); + await expect(mode.getByRole("button", { name: "Solar", exact: true })).toBeVisible(); + await expect(mode.getByRole("button", { name: "Min+Solar" })).toHaveCount(0); + }); + + test("min current and phases but no max current for switch device", async ({ page }) => { + const lp = page.getByTestId("loadpoint").first(); + await lp.getByTestId("loadpoint-settings-button").last().click(); + const modal = page.getByTestId("loadpoint-settings-modal").first(); + await expectModalVisible(modal); + // min current and phases feed the solar switch-on threshold and stay + await expect(modal.getByText("Min. Current")).toBeVisible(); + await expect(modal.getByText("Phases")).toBeVisible(); + await expect(modal.getByText("Max. Current")).toHaveCount(0); + }); +});