From 4645b67e0c37f8c203c077b4d001662b3f2feeae Mon Sep 17 00:00:00 2001 From: Michael Geers Date: Fri, 10 Oct 2025 09:47:45 +0200 Subject: [PATCH] Config UI: add ext meter (#24044) --- assets/js/components/Config/ChargerModal.vue | 12 +- .../Config/DeviceModal/DeviceModalBase.vue | 74 ++++++-- .../Config/DeviceModal/TemplateSelector.vue | 2 + .../js/components/Config/DeviceModal/index.ts | 27 ++- assets/js/components/Config/MeterCard.vue | 94 ++++++++++ assets/js/components/Config/MeterModal.vue | 112 ++++++++---- assets/js/components/Config/VehicleModal.vue | 18 +- assets/js/types/evcc.ts | 3 +- assets/js/views/Config.vue | 129 ++++++-------- i18n/de.json | 20 ++- i18n/en.json | 20 ++- tests/config-ext-meter.spec.ts | 163 ++++++++++++++++++ 12 files changed, 504 insertions(+), 170 deletions(-) create mode 100644 assets/js/components/Config/MeterCard.vue create mode 100644 tests/config-ext-meter.spec.ts 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" > @@ -63,6 +64,7 @@ export default defineComponent({ modelValue: String as PropType, productName: String, groups: Array as PropType, + disabled: Boolean, }, emits: ["update:modelValue", "change"], computed: { diff --git a/assets/js/components/Config/DeviceModal/index.ts b/assets/js/components/Config/DeviceModal/index.ts index c1eeea7db..49041e1f3 100644 --- a/assets/js/components/Config/DeviceModal/index.ts +++ b/assets/js/components/Config/DeviceModal/index.ts @@ -1,4 +1,4 @@ -import type { DeviceType } from "@/types/evcc"; +import type { DeviceType, MeterTemplateUsage } from "@/types/evcc"; import { ConfigType } from "@/types/evcc"; import api from "@/api"; @@ -15,7 +15,7 @@ export type Template = { }; }; -export type TemplateType = "vehicle" | "battery" | "grid" | "pv" | "charger" | "aux" | "ext"; +export type TemplateParamUsage = "vehicle" | "battery" | "grid" | "pv" | "charger" | "aux" | "ext"; export type TemplateParam = { Name: string; @@ -24,7 +24,7 @@ export type TemplateParam = { Deprecated: boolean; Default?: string | number | boolean; Choice?: string[]; - Usages?: TemplateType[]; + Usages?: TemplateParamUsage[]; }; export type ModbusCapability = "rs485" | "tcpip"; @@ -38,14 +38,27 @@ export type ModbusParam = TemplateParam & { export type DeviceValues = { type: ConfigType; - icon: string | undefined; - deviceProduct: string | undefined; - yaml: string | undefined; + icon?: string; + deviceProduct?: string; + yaml?: string; template: string | null; + deviceTitle?: string; + deviceIcon?: string; + usage?: MeterTemplateUsage; + heating?: boolean; + integrateddevice?: boolean; + stationid?: string; [key: string]: any; }; -export type ApiData = Record; +export type ApiData = { + type?: ConfigType; + icon?: string; + usage?: MeterTemplateUsage; + title?: string; + identifiers?: string[]; + [key: string]: any; +}; export function handleError(e: any, msg: string) { console.error(e); diff --git a/assets/js/components/Config/MeterCard.vue b/assets/js/components/Config/MeterCard.vue new file mode 100644 index 000000000..46ff567dd --- /dev/null +++ b/assets/js/components/Config/MeterCard.vue @@ -0,0 +1,94 @@ + + + + + + + + + + + + + diff --git a/assets/js/components/Config/MeterModal.vue b/assets/js/components/Config/MeterModal.vue index 91858b3dd..2b742e6c5 100644 --- a/assets/js/components/Config/MeterModal.vue +++ b/assets/js/components/Config/MeterModal.vue @@ -1,7 +1,7 @@ {{ $t(`config.${meterType}.description`) }}