diff --git a/assets/js/components/Config/ChargerModal.vue b/assets/js/components/Config/ChargerModal.vue index 4db233a11..12d3896f3 100644 --- a/assets/js/components/Config/ChargerModal.vue +++ b/assets/js/components/Config/ChargerModal.vue @@ -106,7 +106,7 @@ export default defineComponent({ this.currentTemplate?.Params.some((p: TemplateParam) => p.Name === "connector") && this.currentTemplate?.Params.some((p: TemplateParam) => p.Name === "stationid"); if (isOcpp && this.currentValues) { - return `ws://${window.location.hostname}:8887/${this.currentValues["stationid"] || ""}`; + return `ws://${window.location.hostname}:8887/${this.currentValues.stationid || ""}`; } return null; }, @@ -174,9 +174,9 @@ export default defineComponent({ ); }, transformApiData(data: ApiData): ApiData { - if (this.isYamlInput(data["type"] as ConfigType)) { + if (data.type && this.isYamlInput(data.type)) { // Icon is extracted from yaml on GET for UI purpose only. Don't write it back. - delete data["icon"]; + delete data.icon; } return data; }, @@ -189,7 +189,7 @@ export default defineComponent({ ConfigType.SgReadyBoost, ].includes(type); }, - async handleTemplateChange(e: Event, values: DeviceValues) { + handleTemplateChange(e: Event, values: DeviceValues) { const value = (e.target as HTMLSelectElement).value as ConfigType; if (this.isYamlInput(value)) { values.type = value; @@ -210,8 +210,8 @@ export default defineComponent({ } }); // default heater icon - if (hasParam("icon") && values["icon"] === undefined) { - values["icon"] = "heater"; + if (hasParam("icon") && values.icon === undefined) { + values.icon = "heater"; } } }, diff --git a/assets/js/components/Config/DeviceModal/DeviceModalBase.vue b/assets/js/components/Config/DeviceModal/DeviceModalBase.vue index 9f3e13df5..5c2d36a7a 100644 --- a/assets/js/components/Config/DeviceModal/DeviceModalBase.vue +++ b/assets/js/components/Config/DeviceModal/DeviceModalBase.vue @@ -175,11 +175,15 @@ export default defineComponent({ // Optional: provide template options from parent (to avoid circular dependency) provideTemplateOptions: Function as PropType<(products: Product[]) => TemplateGroup[]>, // Optional: handle template change (receives event and values, allows setting values.yaml) - onTemplateChange: Function as PropType<(e: Event, values: DeviceValues) => Promise>, + onTemplateChange: Function as PropType<(e: Event, values: DeviceValues) => void>, // Optional: default template to select when opening modal for new devices defaultTemplate: String, + // Optional: callback after configuration is loaded (receives values) + onConfigurationLoaded: Function as PropType<(values: DeviceValues) => void>, + // Optional: external template selection control (for parent to reset template) + externalTemplate: String as PropType, }, - emits: ["added", "updated", "removed", "close", "template-changed"], + emits: ["added", "updated", "removed", "close", "template-changed", "update:externalTemplate"], data() { return { isModalVisible: false, @@ -289,10 +293,7 @@ export default defineComponent({ return this.templateName || this.showYamlInput; }, showYamlInput() { - if (this.isYamlInputType) { - return this.isYamlInputType(this.values.type); - } - return this.values.type === ConfigType.Custom; + return this.isYamlInputTypeByValue(this.values.type); }, showTemplateSelector() { return this.computedTemplateOptions.length > 0; @@ -309,15 +310,22 @@ export default defineComponent({ this.loadProducts(); if (this.id !== undefined) { this.loadConfiguration(); + } else { + // For new devices, apply defaults immediately (e.g., default icons based on meter type) + this.applyDefaults(); } } }, templateName(newValue, oldValue) { + // Sync back to parent if using externalTemplate + if (this.externalTemplate !== undefined && newValue !== this.externalTemplate) { + this.$emit("update:externalTemplate", newValue); + } + + console.log("templateName changed", { newValue, oldValue }); // Reset values when template changes (except on initial load or when switching to YAML input) // YAML input types set values.type and values.yaml in handleTemplateChange callback - const isYamlInput = - this.isYamlInputType && newValue && this.isYamlInputType(newValue as any); - if (oldValue != null && !isYamlInput) { + if (oldValue != null) { if (this.preserveOnTemplateChange) { const preserved: Record = {}; this.preserveOnTemplateChange.forEach((field) => { @@ -331,7 +339,11 @@ export default defineComponent({ this.reset(); } } - this.loadTemplate(); + + const isYamlInput = this.isYamlInputTypeByValue(newValue as ConfigType); + if (!isYamlInput) { + this.loadTemplate(); + } }, usage() { // Reload products when usage changes (e.g., meter type selection) @@ -339,6 +351,19 @@ export default defineComponent({ // Apply defaults when usage changes (e.g., set default icon for meter type) this.applyDefaults(); }, + externalTemplate(newValue) { + // Allow parent to control template selection + if (newValue !== this.templateName) { + this.templateName = newValue; + } + }, + showMainContent(visible) { + // When main content becomes visible (e.g., meter type selected in MeterModal), + // apply defaults like icon based on type + if (visible) { + this.applyDefaults(); + } + }, values: { handler() { this.test = initialTestState(); @@ -360,13 +385,18 @@ export default defineComponent({ this.values.type = device.type; this.values.deviceProduct = device.deviceProduct; if (device.deviceTitle !== undefined) { - this.values["deviceTitle"] = device.deviceTitle; + this.values.deviceTitle = device.deviceTitle; } if (device.deviceIcon !== undefined) { - this.values["deviceIcon"] = device.deviceIcon; + this.values.deviceIcon = device.deviceIcon; } this.applyDefaults(); this.templateName = this.values.template; + + // Allow parent to handle post-load logic + if (this.onConfigurationLoaded) { + this.onConfigurationLoaded(this.values); + } } catch (e) { console.error(e); } @@ -485,13 +515,13 @@ export default defineComponent({ this.$emit("close"); this.isModalVisible = false; }, - async handleTemplateChange(e: Event) { - // Allow parent to handle custom logic (e.g., loading default YAML) - if (this.onTemplateChange) { - await this.onTemplateChange(e, this.values); - } - // Emit to parent for notification - this.$emit("template-changed", e); + handleTemplateChange(e: Event) { + // ensure this triggers after tempateName watcher + this.$nextTick(() => { + if (this.onTemplateChange) { + this.onTemplateChange(e, this.values); + } + }); }, handleSave(force: boolean) { if (this.isNew) { @@ -503,6 +533,12 @@ export default defineComponent({ handleRemove() { this.remove(); }, + isYamlInputTypeByValue(value: ConfigType): boolean { + if (this.isYamlInputType) { + return this.isYamlInputType(value); + } + return value === ConfigType.Custom; + }, }, }); diff --git a/assets/js/components/Config/DeviceModal/TemplateSelector.vue b/assets/js/components/Config/DeviceModal/TemplateSelector.vue index e7dd598bb..e663ca8cd 100644 --- a/assets/js/components/Config/DeviceModal/TemplateSelector.vue +++ b/assets/js/components/Config/DeviceModal/TemplateSelector.vue @@ -6,6 +6,7 @@ ref="select" v-model="modelProxy" class="form-select w-100" + :disabled="disabled" @change="changed" > @@ -82,7 +96,7 @@ import PropertyField from "./PropertyField.vue"; import NewDeviceButton from "./NewDeviceButton.vue"; import DeviceModalBase from "./DeviceModal/DeviceModalBase.vue"; import { ICONS } from "../VehicleIcon/VehicleIcon.vue"; -import { ConfigType, type SelectedMeterType } from "@/types/evcc"; +import { ConfigType, type MeterType, type MeterTemplateUsage } from "@/types/evcc"; import type { ModalFade } from "../Helper/GenericModal.vue"; import { type DeviceValues, @@ -90,9 +104,9 @@ import { type Product, type TemplateParam, type ApiData, - type TemplateType, } from "./DeviceModal"; import { customTemplateOption, type TemplateGroup } from "./DeviceModal/TemplateSelector.vue"; +import defaultMeterYaml from "./defaultYaml/meter.yaml?raw"; const initialValues = { type: ConfigType.Template, @@ -108,7 +122,7 @@ const CUSTOM_FIELDS = ["usage", "modbus"]; const defaultIcons: Record = { aux: "smartconsumer", - ext: "meter", + ext: "generic", }; export default defineComponent({ @@ -121,21 +135,17 @@ export default defineComponent({ }, props: { id: Number, - type: { - type: String as () => SelectedMeterType | undefined, - default: undefined, - }, - typeChoices: { - type: Array as () => string[], - default: () => ["pv", "battery", "aux", "ext"], - }, + type: { type: String as PropType, default: null }, + typeChoices: { type: Array as () => MeterType[], default: () => [] }, fade: String as PropType, isSponsor: Boolean, }, emits: ["added", "updated", "removed", "close"], data() { return { - selectedType: null as string | null, + selectedType: null as MeterType | null, + extMeterUsage: "charge" as MeterTemplateUsage, + selectedTemplate: null as string | null, iconChoices: ICONS, initialValues, customFields: CUSTOM_FIELDS, @@ -153,10 +163,21 @@ export default defineComponent({ } return this.$t(`config.${this.meterType}.titleEdit`); }, - meterType(): Exclude | null { - // @ts-expect-error either this.type or this.selectedType is given + meterType(): MeterType | null { return this.type || this.selectedType; }, + templateUsage(): MeterTemplateUsage | undefined { + if (!this.meterType) return undefined; + + // For ext meters, the user selects the template usage explicitly + // For other meter types, the meter type IS the template usage + if (this.meterType === "ext") { + return this.extMeterUsage; + } + // For non-ext meters, meterType directly maps to template usage + // (grid->grid, pv->pv, battery->battery, charge->charge, aux->aux) + return this.meterType; + }, hasDeviceTitle(): boolean { return ["pv", "battery", "aux", "ext"].includes(this.meterType || ""); }, @@ -169,9 +190,21 @@ export default defineComponent({ isNew(): boolean { return this.id === undefined; }, + extMeterUsageOptions() { + return ["grid", "pv", "battery", "charge", "aux"].map((key) => ({ + name: this.$t(`config.meter.usage.${key}`), + key, + })); + }, }, methods: { - selectType(type: string) { + onConfigurationLoaded(values: DeviceValues) { + // Restore extMeterUsage when editing an existing ext meter + if (this.meterType === "ext" && values.usage) { + this.extMeterUsage = values.usage; + } + }, + selectType(type: MeterType) { this.selectedType = type; }, provideTemplateOptions(products: Product[]): TemplateGroup[] { @@ -193,7 +226,9 @@ export default defineComponent({ const filtered = params.filter( (p) => !CUSTOM_FIELDS.includes(p.Name) && - (p.Usages && this.meterType ? p.Usages.includes(this.meterType) : true) + (p.Usages && this.templateUsage + ? p.Usages.includes(this.templateUsage as any) + : true) ); // Make capacity non-advanced for battery meters @@ -206,27 +241,33 @@ export default defineComponent({ }, transformApiData(data: ApiData, values: DeviceValues): ApiData { if (values.type === ConfigType.Template) { - data["usage"] = this.meterType || undefined; + // Set the template usage (what the template should do) + // For ext meters: user-selected usage (grid, pv, battery, charge, aux) + // For other meters: meterType itself is the usage + data.usage = this.templateUsage; } return data; }, - isYamlInput(type: ConfigType): boolean { - return type === ConfigType.Custom; - }, - async handleTemplateChange(e: Event, values: DeviceValues) { + handleTemplateChange(e: Event, values: DeviceValues) { + console.log("[MeterModal] handleTemplateChange", { e, values }); const value = (e.target as HTMLSelectElement).value; if (value === ConfigType.Custom) { - const defaultYaml = await import("./defaultYaml/meter.yaml?raw"); values.type = ConfigType.Custom; - values.yaml = defaultYaml.default; + values.yaml = defaultMeterYaml; + console.log("[MeterModal] set type/yaml", { type: values.type, yaml: values.yaml }); } }, applyCustomDefaults(_template: Template | null, values: DeviceValues) { - // Apply default icon when template is loaded - if (this.meterType && !values["deviceIcon"]) { - values["deviceIcon"] = defaultIcons[this.meterType] || ""; + // Apply default icon when template is loaded or meter type is selected + if (this.meterType && !values.deviceIcon) { + values.deviceIcon = defaultIcons[this.meterType] || ""; } }, + extMeterUsageChanged() { + // When ext meter usage changes, reset template selection to force user to reselect + // This triggers product reload via effectiveUsage computed property change + this.selectedTemplate = null; + }, handleAdded(name: string) { this.$emit("added", this.meterType, name); }, @@ -235,6 +276,7 @@ export default defineComponent({ }, handleClose() { this.selectedType = null; + this.extMeterUsage = "charge"; this.$emit("close"); }, }, diff --git a/assets/js/components/Config/VehicleModal.vue b/assets/js/components/Config/VehicleModal.vue index 5438a9c8c..6336317fe 100644 --- a/assets/js/components/Config/VehicleModal.vue +++ b/assets/js/components/Config/VehicleModal.vue @@ -7,7 +7,6 @@ :modal-title="$t(`config.vehicle.${isNew ? 'titleAdd' : 'titleEdit'}`)" :provide-template-options="provideTemplateOptions" :initial-values="initialValues" - :is-yaml-input-type="isYamlInput" :transform-api-data="transformApiData" :filter-template-params="filterTemplateParams" :on-template-change="handleTemplateChange" @@ -141,6 +140,7 @@ import DeviceModalBase from "./DeviceModal/DeviceModalBase.vue"; import { ConfigType } from "@/types/evcc"; import { customTemplateOption, type TemplateGroup } from "./DeviceModal/TemplateSelector.vue"; import type { Product, ApiData, DeviceValues, TemplateParam } from "./DeviceModal"; +import defaultVehicleYaml from "./defaultYaml/vehicle.yaml?raw"; const initialValues = { type: ConfigType.Template, @@ -229,23 +229,19 @@ export default defineComponent({ }, transformApiData(data: ApiData, values: DeviceValues): ApiData { if (values.type === ConfigType.Custom) { - delete data["icon"]; - delete data["title"]; + delete data.icon; + delete data.title; } - if (Array.isArray(data["identifiers"])) { - data["identifiers"] = data["identifiers"].map((i) => i.trim()).filter((i) => i); + if (Array.isArray(data.identifiers)) { + data.identifiers = data.identifiers.map((i) => i.trim()).filter((i) => i); } return data; }, - isYamlInput(type: ConfigType) { - return type === ConfigType.Custom; - }, - async handleTemplateChange(e: Event, values: DeviceValues) { + handleTemplateChange(e: Event, values: DeviceValues) { const value = (e.target as HTMLSelectElement).value; if (value === ConfigType.Custom) { - const defaultYaml = await import("./defaultYaml/vehicle.yaml?raw"); values.type = ConfigType.Custom; - values.yaml = defaultYaml.default; + values.yaml = defaultVehicleYaml; } }, }, diff --git a/assets/js/types/evcc.ts b/assets/js/types/evcc.ts index a9faad570..9b7a1a714 100644 --- a/assets/js/types/evcc.ts +++ b/assets/js/types/evcc.ts @@ -415,7 +415,8 @@ export interface SelectOption { } export type DeviceType = "charger" | "meter" | "vehicle" | "loadpoint"; -export type SelectedMeterType = "grid" | "pv" | "battery" | "charge" | "aux" | "ext"; +export type MeterType = "grid" | "pv" | "battery" | "charge" | "aux" | "ext"; +export type MeterTemplateUsage = "grid" | "pv" | "battery" | "charge" | "aux"; // see https://stackoverflow.com/a/54178819 type Omit = Pick>; diff --git a/assets/js/views/Config.vue b/assets/js/views/Config.vue index a55f5ed7a..2c18144bd 100644 --- a/assets/js/views/Config.vue +++ b/assets/js/views/Config.vue @@ -80,22 +80,14 @@

{{ $t("config.section.grid") }} đŸ§Ș

- - - - + :meter="gridMeter" + meter-type="grid" + :has-error="hasDeviceError('meter', gridMeter.name)" + :tags="deviceTags('meter', gridMeter.name)" + @edit="editMeter" + />

{{ $t("config.section.meter") }} đŸ§Ș

- - - - - + - - - + :meter="meter" + meter-type="battery" + :has-error="hasDeviceError('meter', meter.name)" + :tags="deviceTags('meter', meter.name)" + @edit="editMeter" + /> {{ $t("config.section.additionalMeter") }} đŸ§Ș
- - - - + :meter="meter" + meter-type="aux" + :has-error="hasDeviceError('meter', meter.name)" + :tags="deviceTags('meter', meter.name)" + @edit="editMeter" + /> + this.editMeter("charge", meter?.id)); }, - editMeter(type: SelectedMeterType, id?: number) { + editMeter(type: MeterType, id?: number) { this.selectedMeterType = type; this.selectedMeterId = id; this.$nextTick(() => this.meterModal().show()); }, - newMeter(type: SelectedMeterType) { + newMeter(type: MeterType) { this.selectedMeterId = undefined; this.selectedMeterType = type; this.$nextTick(() => this.meterModal().show()); @@ -851,13 +818,13 @@ export default defineComponent({ addSolarBatteryMeter() { this.selectedMeterId = undefined; this.selectedMeterType = undefined; - this.selectedMeterTypeChoices = ["pv", "battery"]; + this.selectedMeterTypeChoices = ["pv", "battery"] as MeterType[]; this.$nextTick(() => this.meterModal().show()); }, newAdditionalMeter() { this.selectedMeterId = undefined; this.selectedMeterType = undefined; - this.selectedMeterTypeChoices = ["aux", "ext"]; + this.selectedMeterTypeChoices = ["aux", "ext"] as MeterType[]; this.$nextTick(() => this.meterModal().show()); }, editCharger(id?: number, loadpointType?: LoadpointType) { @@ -912,7 +879,7 @@ export default defineComponent({ yamlChanged() { this.loadDirty(); }, - meterAdded(type: SelectedMeterType, name: string) { + meterAdded(type: MeterType, name: string) { if (type === "charge") { // update loadpoint ( @@ -946,7 +913,7 @@ export default defineComponent({ } this.meterChanged(); }, - meterRemoved(type: SelectedMeterType) { + meterRemoved(type: MeterType) { if (type === "charge") { // update loadpoint ( diff --git a/i18n/de.json b/i18n/de.json index 54f1566d9..505acbdbd 100644 --- a/i18n/de.json +++ b/i18n/de.json @@ -143,9 +143,9 @@ "title": "EEBus" }, "ext": { - "description": "Kann fĂŒr Lastmanagement oder Statistikzwecke verwendet werden.", - "titleAdd": "ZusĂ€tzlichen ZĂ€hler hinzufĂŒgen", - "titleEdit": "ZusĂ€tzlichen ZĂ€hler bearbeiten" + "description": "Erfasst Energiewerte ungeregelter Verbraucher (bspw. KĂŒhlschrank, Waschmaschine, etc.) fĂŒr Statistikzwecke. Diese ZĂ€hler können auch fĂŒr Lastmanagement verwendet werden (bspw. Unterverteilung).", + "titleAdd": "VerbrauchszĂ€hler hinzufĂŒgen", + "titleEdit": "VerbrauchszĂ€hler bearbeiten" }, "form": { "danger": "Achtung", @@ -196,7 +196,8 @@ "title": "HEMS" }, "icon": { - "change": "Ă€ndern" + "change": "Ă€ndern", + "label": "Symbol" }, "influx": { "description": "Schreibt Ladedaten und andere Metriken in InfluxDB. Verwende Grafana oder andere Tools, um die Daten zu visualisieren.", @@ -344,13 +345,22 @@ "option": { "aux": "Intelligenten Verbraucher hinzufĂŒgen", "battery": "Hausbatterie hinzufĂŒgen", - "ext": "ZusĂ€tzlichen ZĂ€hler hinzufĂŒgen", + "ext": "RegulĂ€ren Verbraucher hinzufĂŒgen", "pv": "PV-Anlage hinzufĂŒgen" }, "save": "Speichern", "specific": "Spezifische Integrationen", "template": "Hersteller", "titleChoice": "Was möchtest du hinzufĂŒgen?", + "titleLabel": "Titel", + "usage": { + "aux": "Intelligenter Verbraucher", + "battery": "BatteriezĂ€hler", + "charge": "LadegerĂ€tzĂ€hler", + "grid": "NetzzĂ€hler", + "label": "Verwendung", + "pv": "PV-ZĂ€hler" + }, "validateSave": "ÜberprĂŒfen & speichern" }, "modbus": { diff --git a/i18n/en.json b/i18n/en.json index ce804ff0f..1cc405198 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -143,9 +143,9 @@ "title": "EEBus" }, "ext": { - "description": "Can be used for load management or statistics purposes.", - "titleAdd": "Add External Meter", - "titleEdit": "Edit External Meter" + "description": "Records energy values of uncontrolled consumers (e.g. refrigerator, washing machine, etc.) for statistical purposes. These meters can also be used for load management (e.g. sub-distribution).", + "titleAdd": "Add Consumer Meter", + "titleEdit": "Edit Consumer Meter" }, "form": { "danger": "Danger", @@ -196,7 +196,8 @@ "title": "HEMS" }, "icon": { - "change": "change" + "change": "change", + "label": "Icon" }, "influx": { "description": "Writes charging data and other metrics to InfluxDB. Use Grafana or other tools to visualize the data.", @@ -344,13 +345,22 @@ "option": { "aux": "Add self-regulating consumer", "battery": "Add battery meter", - "ext": "Add external meter", + "ext": "Add regular consumer", "pv": "Add solar meter" }, "save": "Save", "specific": "Specific integrations", "template": "Manufacturer", "titleChoice": "What Do You Want To Add?", + "titleLabel": "Title", + "usage": { + "aux": "Self-regulating consumer meter", + "battery": "Battery meter", + "charge": "Charger meter", + "grid": "Grid meter", + "label": "Usage", + "pv": "Solar meter" + }, "validateSave": "Validate & save" }, "modbus": { diff --git a/tests/config-ext-meter.spec.ts b/tests/config-ext-meter.spec.ts new file mode 100644 index 000000000..fa3d6a4ad --- /dev/null +++ b/tests/config-ext-meter.spec.ts @@ -0,0 +1,163 @@ +import { test, expect } from "@playwright/test"; +import { start, stop, restart, baseUrl } from "./evcc"; +import { + editorClear, + editorPaste, + enableExperimental, + expectModalHidden, + expectModalVisible, +} from "./utils"; + +const CONFIG_GRID_ONLY = "config-grid-only.evcc.yaml"; + +test.use({ baseURL: baseUrl() }); + +test.beforeEach(async () => { + await start(CONFIG_GRID_ONLY); +}); +test.afterEach(async () => { + await stop(); +}); + +test.describe("ext meter", async () => { + test("template-based ext meter", async ({ page }) => { + await page.goto("/#/config"); + await enableExperimental(page, false); + await expect(page.getByTestId("ext")).toHaveCount(0); + + await page.getByRole("button", { name: "Add additional meter" }).click(); + const meterModal = page.getByTestId("meter-modal"); + await expectModalVisible(meterModal); + await meterModal.getByRole("button", { name: "Add regular consumer" }).click(); + + await expect(meterModal.getByLabel("Usage")).toHaveValue("charge"); + + await meterModal.getByLabel("Manufacturer").selectOption("cFos PowerBrain Meter"); + await page.waitForLoadState("networkidle"); + + await meterModal.getByLabel("Usage").selectOption("battery"); + await page.waitForLoadState("networkidle"); + + await meterModal.getByLabel("Manufacturer").selectOption("Demo battery"); + await page.waitForLoadState("networkidle"); + + await expect(meterModal.getByLabel("Charge")).toBeVisible(); + await meterModal.getByLabel("Title").fill("House battery"); + await meterModal.getByLabel("Charge").fill("75"); + + const testResult = meterModal.getByTestId("test-result"); + await testResult.getByRole("link", { name: "validate" }).click(); + await expect(testResult).toContainText("Status: successful"); + await expect(testResult).toContainText("75.0%"); + + await meterModal.getByRole("button", { name: "Save" }).click(); + await expectModalHidden(meterModal); + + await expect(page.getByTestId("ext")).toHaveCount(1); + await expect(page.getByTestId("ext")).toContainText("House battery"); + + await restart(CONFIG_GRID_ONLY); + await page.reload(); + await expect(page.getByTestId("ext")).toHaveCount(1); + + await page.getByTestId("ext").getByRole("button", { name: "edit" }).click(); + await expectModalVisible(meterModal); + await expect(meterModal.getByLabel("Title")).toHaveValue("House battery"); + await expect(meterModal.getByLabel("Usage")).toBeDisabled(); + await expect(meterModal.getByLabel("Manufacturer")).toBeDisabled(); + + await meterModal.getByLabel("Charge").clear(); + await meterModal.getByLabel("Charge").fill("85"); + await testResult.getByRole("link", { name: "validate" }).click(); + await expect(testResult).toContainText("85.0%"); + await meterModal.getByRole("button", { name: "Save" }).click(); + await expectModalHidden(meterModal); + + await page.getByTestId("ext").getByRole("button", { name: "edit" }).click(); + await expectModalVisible(meterModal); + await expect(meterModal.getByLabel("Charge")).toHaveValue("85"); + + await meterModal.getByRole("button", { name: "Delete" }).click(); + await expectModalHidden(meterModal); + await expect(page.getByTestId("ext")).toHaveCount(0); + + await restart(CONFIG_GRID_ONLY); + await page.reload(); + await expect(page.getByTestId("ext")).toHaveCount(0); + await expect(page.getByTestId("fatal-error")).not.toBeVisible(); + }); + + test("switch from template to custom ext meter", async ({ page }) => { + await page.goto("/#/config"); + await enableExperimental(page, false); + + await page.getByRole("button", { name: "Add additional meter" }).click(); + const meterModal = page.getByTestId("meter-modal"); + await expectModalVisible(meterModal); + await meterModal.getByRole("button", { name: "Add regular consumer" }).click(); + + await meterModal.getByLabel("Title").fill("Custom ext meter"); + await meterModal.getByLabel("Usage").selectOption("battery"); + await meterModal.getByLabel("Manufacturer").selectOption("Demo battery"); + await meterModal.getByLabel("Charge").fill("50"); + + await meterModal.getByLabel("Manufacturer").selectOption("User-defined device"); + await page.waitForLoadState("networkidle"); + + const editor = meterModal.getByTestId("yaml-editor"); + await expect(editor).toContainText("power: # current power"); + + const testResult = meterModal.getByTestId("test-result"); + await testResult.getByRole("link", { name: "validate" }).click(); + await expect(testResult).toContainText("Status: successful"); + await expect(testResult).toContainText("1.0 kW"); + + await meterModal.getByRole("button", { name: "Save" }).click(); + await expectModalHidden(meterModal); + + await expect(page.getByTestId("ext")).toHaveCount(1); + await expect(page.getByTestId("ext")).toContainText("Custom ext meter"); + + await restart(CONFIG_GRID_ONLY); + await page.reload(); + await expect(page.getByTestId("ext")).toHaveCount(1); + + await page.getByTestId("ext").getByRole("button", { name: "edit" }).click(); + await expectModalVisible(meterModal); + await expect(meterModal.getByLabel("Title")).toHaveValue("Custom ext meter"); + await expect(meterModal.getByLabel("Usage")).toBeDisabled(); + await expect(meterModal.getByLabel("Manufacturer")).toHaveValue("User-defined device"); + + await page.waitForLoadState("networkidle"); + await expect(editor).toContainText("value: 1000 # W"); + + await editorClear(editor); + await editorPaste( + editor, + page, + `power: + source: const + value: 2000 # W` + ); + + await testResult.getByRole("link", { name: "validate" }).click(); + await expect(testResult).toContainText("Status: successful"); + await expect(testResult).toContainText("2.0 kW"); + + await meterModal.getByRole("button", { name: "Save" }).click(); + await expectModalHidden(meterModal); + + await page.getByTestId("ext").getByRole("button", { name: "edit" }).click(); + await expectModalVisible(meterModal); + await page.waitForLoadState("networkidle"); + await expect(editor).toContainText("value: 2000 # W"); + + await meterModal.getByRole("button", { name: "Delete" }).click(); + await expectModalHidden(meterModal); + await expect(page.getByTestId("ext")).toHaveCount(0); + + await restart(CONFIG_GRID_ONLY); + await page.reload(); + await expect(page.getByTestId("ext")).toHaveCount(0); + }); +});