Loadpoint: adapt UI for integrated and switch devices (#30909)

Co-authored-by: Michael Geers <michael@geers.tv>
This commit is contained in:
andig 2026-06-19 18:25:59 +02:00 • committed by GitHub
parent 5f9047531d
commit eed0649eaa
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 101 additions and 12 deletions

View file

@ -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":

View file

@ -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"
/>
</FormRow>
@ -306,7 +300,7 @@
/>
</FormRow>
<h6>
<h6 v-if="!chargerIsSwitchDevice">
{{ $t("config.loadpoint.electricalTitle") }}
<small class="text-muted">{{
$t("config.loadpoint.electricalSubtitle")
@ -314,6 +308,7 @@
</h6>
<FormRow
v-if="!chargerIsSwitchDevice"
id="chargerPower"
:label="$t('config.loadpoint.chargerTypeLabel')"
:help="
@ -346,7 +341,10 @@
/>
</FormRow>
<div v-if="chargerPower === 'other'" class="row ms-3 mb-5">
<div
v-if="!chargerIsSwitchDevice && chargerPower === 'other'"
class="row ms-3 mb-5"
>
<FormRow
id="loadpointMinCurrent"
:label="$t('config.loadpoint.minCurrentLabel')"
@ -634,6 +632,7 @@ import InvalidReferenceAlert from "./InvalidReferenceAlert.vue";
import { handleError, customChargerName, createDeviceUtils } from "./DeviceModal";
import { getModal, openModal, replaceModal, closeModal } from "@/configModal";
import {
CHARGE_MODE,
LOADPOINT_TYPE,
type DeviceType,
type LoadpointType,
@ -646,6 +645,8 @@ import {
const nsPerMin = 60 * 1e9;
const { OFF, PV, MINPV, NOW } = CHARGE_MODE;
const defaultValues = {
id: undefined,
title: "",
@ -763,6 +764,9 @@ export default {
chargerIsIntegratedDevice() {
return this.chargerStatus.integratedDevice?.value || false;
},
chargerIsSwitchDevice() {
return this.chargerStatus.switchDevice?.value || false;
},
chargerIsHeating() {
return this.chargerStatus.heating?.value === true;
},
@ -797,6 +801,12 @@ export default {
{ value: 3, name: this.$t("config.loadpoint.phases3p") },
];
},
defaultModeOptions(): { key: CHARGE_MODE; name: string }[] {
// empty option is provided by PropertyField placeholder
// switch devices have no current control, so minpv does not apply
const modes = this.chargerIsSwitchDevice ? [OFF, PV, NOW] : [OFF, PV, MINPV, NOW];
return modes.map((key) => ({ key, name: this.$t(`main.mode.${key}`) }));
},
showCircuit() {
return this.circuits.length > 0 || !!this.values.circuit;
},

View file

@ -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

View file

@ -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

View file

@ -167,6 +167,7 @@ export default defineComponent({
chargerFeatureIntegratedDevice: Boolean,
chargerFeatureHeating: Boolean,
chargerFeatureContinuous: Boolean,
chargerFeatureSwitchDevice: Boolean,
chargerIcon: String as PropType<string | null>,
// 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);
},

View file

@ -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];

View file

@ -81,7 +81,7 @@
</div>
</div>
<div class="mb-3 row">
<div v-if="!switchDevice" class="mb-3 row">
<label :for="formId('maxcurrent')" class="col-sm-4 col-form-label pt-0 pt-sm-2">
{{ $t("main.loadpointSettings.maxCurrent.label") }}
</label>
@ -194,6 +194,9 @@ export default defineComponent({
minCurrent() {
return this.loadpoint?.minCurrent;
},
switchDevice() {
return this.loadpoint?.chargerFeatureSwitchDevice;
},
batteryBoostLimit() {
return this.loadpoint?.batteryBoostLimit;
},

View file

@ -193,6 +193,7 @@
"singlePhase": "Einphasig",
"soc": "Ladestand",
"solarForecast": "PV-Vorhersage",
"switchDevice": "Stromregelung",
"temp": "Temperatur",
"topic": "Thema",
"url": "URL",

View file

@ -193,6 +193,7 @@
"singlePhase": "Single phase",
"soc": "Charge",
"solarForecast": "Solar forecast",
"switchDevice": "Current control",
"temp": "Temperature",
"topic": "Topic",
"url": "URL",

View file

@ -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)
}

View file

@ -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 () => {

View file

@ -48,4 +48,4 @@ chargers:
source: js
script: |
55
features: [integrateddevice, heating]
features: [integrateddevice, heating, switchdevice]

View file

@ -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);
});
});