From f3b60644e8cfbebaac66df60620e00a9a30d7dcf Mon Sep 17 00:00:00 2001 From: Michael Geers Date: Mon, 7 Jul 2025 21:36:32 +0200 Subject: [PATCH] Config UI: add heating support (#22177) --- assets/js/components/Config/ChargerModal.vue | 144 ++++++-- assets/js/components/Config/DeviceCard.vue | 4 +- .../Config/DeviceModal/TemplateSelector.vue | 4 +- .../js/components/Config/DeviceModal/index.ts | 16 + assets/js/components/Config/DeviceTags.vue | 10 +- .../js/components/Config/LoadpointModal.vue | 338 +++++++++++------- assets/js/components/Config/MeterModal.vue | 4 +- .../js/components/Config/NewDeviceButton.vue | 39 +- assets/js/components/Config/PropertyField.vue | 1 - .../{charger.yaml => customCharger.yaml} | 24 +- .../Config/defaultYaml/customHeater.yaml | 54 +++ .../Config/defaultYaml/heatpump.yaml | 23 ++ .../Config/defaultYaml/sgready.yaml | 29 ++ .../Config/defaultYaml/sgreadyBoost.yaml | 15 + .../defaultYaml/switchsocketCharger.yaml | 24 ++ .../defaultYaml/switchsocketHeater.yaml | 25 ++ .../js/components/VehicleIcon/VehicleIcon.vue | 8 +- assets/js/types/evcc.ts | 5 + assets/js/views/Config.vue | 44 +-- i18n/bg.json | 24 +- i18n/cs.json | 24 +- i18n/da.json | 24 +- i18n/de.json | 77 +++- i18n/el.json | 24 +- i18n/en.json | 95 +++-- i18n/fi.json | 24 +- i18n/fr.json | 24 +- i18n/hr.json | 24 +- i18n/hu.json | 24 +- i18n/it.json | 24 +- i18n/lb.json | 24 +- i18n/lt.json | 24 +- i18n/nl.json | 24 +- i18n/pl.json | 12 +- i18n/pt.json | 24 +- i18n/sl.json | 20 +- i18n/sv.json | 32 +- i18n/tr.json | 24 +- i18n/uk.json | 24 +- i18n/zh-Hans.json | 24 +- server/http_config_helper.go | 26 +- .../definition/charger/demo-charger.yaml | 1 + .../definition/charger/demo-heatpump.yaml | 86 +++++ templates/definition/charger/ocpp.yaml | 4 +- templates/definition/charger/plugchoice.yaml | 9 +- tests/boot.spec.ts | 4 +- tests/config-aux.spec.ts | 6 +- tests/config-battery.spec.ts | 4 +- tests/config-circuit.spec.ts | 5 +- tests/config-grid.spec.ts | 2 +- tests/config-loadpoint.spec.ts | 215 +++++++++-- tests/config-messaging.spec.ts | 4 +- tests/config-mqtt.spec.ts | 10 +- tests/config-onboarding.spec.ts | 2 + tests/config-pv.spec.ts | 4 +- tests/config-tariffs.spec.ts | 10 +- tests/config-vehicles.spec.ts | 25 +- tests/config.spec.ts | 2 +- tests/utils.ts | 24 +- util/templates/defaults.yaml | 11 +- 60 files changed, 1399 insertions(+), 486 deletions(-) rename assets/js/components/Config/defaultYaml/{charger.yaml => customCharger.yaml} (67%) create mode 100644 assets/js/components/Config/defaultYaml/customHeater.yaml create mode 100644 assets/js/components/Config/defaultYaml/heatpump.yaml create mode 100644 assets/js/components/Config/defaultYaml/sgready.yaml create mode 100644 assets/js/components/Config/defaultYaml/sgreadyBoost.yaml create mode 100644 assets/js/components/Config/defaultYaml/switchsocketCharger.yaml create mode 100644 assets/js/components/Config/defaultYaml/switchsocketHeater.yaml create mode 100644 templates/definition/charger/demo-heatpump.yaml diff --git a/assets/js/components/Config/ChargerModal.vue b/assets/js/components/Config/ChargerModal.vue index 84a5b20c9..8e2887062 100644 --- a/assets/js/components/Config/ChargerModal.vue +++ b/assets/js/components/Config/ChargerModal.vue @@ -21,7 +21,7 @@ /> LOADPOINT_TYPE | null, default: null }, fade: String, isSponsor: Boolean, }, @@ -177,35 +186,58 @@ export default defineComponent({ computed: { modalTitle() { if (this.isNew) { - return this.$t(`config.charger.titleAdd`); + return this.$t(`config.charger.titleAdd.${this.loadpointType}`); } - return this.$t(`config.charger.titleEdit`); + return this.$t(`config.charger.titleEdit.${this.loadpointType}`); }, modalSize() { - return this.values.type === ConfigType.Custom ? "xl" : undefined; + return this.showYamlInput ? "xl" : undefined; }, templateOptions() { - return [ - { + const result = []; + + if (this.isHeating) { + result.push({ + label: "generic", + options: [ + ...this.products.filter((p) => p.group === "heatinggeneric"), + ...[ + ConfigType.Custom, + ConfigType.SgReadyBoost, + ConfigType.SgReady, + ConfigType.Heatpump, + ConfigType.SwitchSocket, + ].map((type) => + customTemplateOption(this.$t(customChargerName(type, true)), type) + ), + ], + }); + result.push({ + label: "heatingdevices", + options: this.products.filter((p) => p.group === "heating"), + }); + } else { + result.push({ label: "generic", options: [ ...this.products.filter((p) => p.group === "generic"), - customTemplateOption(this.$t("config.general.customOption")), + ...[ConfigType.Custom, ConfigType.SwitchSocket].map((type) => + customTemplateOption(this.$t(customChargerName(type, false)), type) + ), ], - }, - { + }); + result.push({ label: "chargers", options: this.products.filter((p) => !p.group), - }, - { - label: "switchsockets", - options: this.products.filter((p) => p.group === "switchsockets"), - }, - { - label: "heatingdevices", - options: this.products.filter((p) => p.group === "heating"), - }, - ]; + }); + } + + result.push({ + label: "switchsockets", + options: this.products.filter((p) => p.group === "switchsockets"), + }); + + return result; }, templateParams() { const params = this.template?.Params || []; @@ -233,7 +265,7 @@ export default defineComponent({ this.templateParams.some((p) => p.Name === "connector") && this.templateParams.some((p) => p.Name === "stationid"); if (isOcpp) { - return `ws://${window.location.hostname}:8887`; + return `ws://${window.location.hostname}:8887/${this.values["stationid"] || ""}`; } return null; }, @@ -250,7 +282,11 @@ export default defineComponent({ return this.template?.Requirements?.Description; }, productName() { - return this.values.deviceProduct || this.templateName || ""; + return ( + this.values.deviceProduct || + this.templateName || + this.$t(customChargerName(this.values.type, this.isHeating)) + ); }, sponsorTokenRequired() { const requirements = this.template?.Requirements as Requirements | undefined; @@ -269,11 +305,17 @@ export default defineComponent({ isNew() { return this.id === undefined; }, + isHeating() { + return this.loadpointType === LOADPOINT_TYPE.HEATING; + }, isDeletable() { return !this.isNew; }, showActions() { - return this.templateName || this.values.type === ConfigType.Custom; + return this.templateName || this.showYamlInput; + }, + showYamlInput() { + return this.isYamlInput(this.values.type); }, }, watch: { @@ -311,12 +353,29 @@ export default defineComponent({ // TODO: adjust GET response to match POST/PUT formats this.values.type = charger.type; this.values.deviceProduct = charger.deviceProduct; - applyDefaultsFromTemplate(this.template, this.values); + this.applyDefaults(); this.templateName = this.values.template; } catch (e) { console.error(e); } }, + applyDefaults() { + applyDefaultsFromTemplate(this.template, this.values); + if (this.isHeating) { + // enable heating and integrated device params if exist + const hasParam = (name: string) => + this.template?.Params.some((p) => p.Name === name); + ["heating", "integrateddevice"].forEach((param) => { + if (hasParam(param) && this.values[param] === undefined) { + this.values[param] = true; + } + }); + // default heater icon + if (hasParam("icon") && this.values["icon"] === undefined) { + this.values["icon"] = "heater"; + } + } + }, async loadProducts() { if (!this.isModalVisible) { return; @@ -329,11 +388,11 @@ export default defineComponent({ }, async loadTemplate() { this.template = null; - if (!this.templateName || this.templateName === ConfigType.Custom) return; + if (!this.templateName || this.isYamlInput(this.templateName as ConfigType)) return; this.loadingTemplate = true; try { this.template = await device.loadTemplate(this.templateName, this.$i18n?.locale); - applyDefaultsFromTemplate(this.template, this.values); + this.applyDefaults(); } catch (e) { console.error(e); } @@ -407,13 +466,38 @@ export default defineComponent({ this.isModalVisible = false; }, templateChanged(e: Event) { - const value = (e.target as HTMLSelectElement).value; + const value = (e.target as HTMLSelectElement).value as ConfigType; this.reset(); - if (value === ConfigType.Custom) { - this.values.type = ConfigType.Custom; - this.values.yaml = defaultYaml; + if (this.isYamlInput(value)) { + this.values.type = value; + this.values.yaml = this.defaultYaml(value); } }, + defaultYaml(type: ConfigType) { + switch (type) { + case ConfigType.Custom: + return this.isHeating ? customHeaterYaml : customChargerYaml; + case ConfigType.Heatpump: + return heatpumpYaml; + case ConfigType.SwitchSocket: + return this.isHeating ? switchsocketHeaterYaml : switchsocketChargerYaml; + case ConfigType.SgReady: + return sgreadyYaml; + case ConfigType.SgReadyBoost: + return sgreadyBoostYaml; + default: // template + return ""; + } + }, + isYamlInput(type: ConfigType) { + return [ + ConfigType.Custom, + ConfigType.Heatpump, + ConfigType.SwitchSocket, + ConfigType.SgReady, + ConfigType.SgReadyBoost, + ].includes(type); + }, }, }); diff --git a/assets/js/components/Config/DeviceCard.vue b/assets/js/components/Config/DeviceCard.vue index b78282def..f9fe24419 100644 --- a/assets/js/components/Config/DeviceCard.vue +++ b/assets/js/components/Config/DeviceCard.vue @@ -1,5 +1,5 @@ diff --git a/assets/js/components/Config/DeviceModal/index.ts b/assets/js/components/Config/DeviceModal/index.ts index 87b1425c4..29a3de2ff 100644 --- a/assets/js/components/Config/DeviceModal/index.ts +++ b/assets/js/components/Config/DeviceModal/index.ts @@ -57,6 +57,10 @@ export const timeout = 15000; export enum ConfigType { Template = "template", Custom = "custom", + Heatpump = "heatpump", + SwitchSocket = "switchsocket", + SgReady = "sgready", + SgReadyBoost = "sgready-boost", } export function applyDefaultsFromTemplate(template: Template | null, values: DeviceValues) { @@ -68,6 +72,18 @@ export function applyDefaultsFromTemplate(template: Template | null, values: Dev }); } +export function customChargerName(type: ConfigType, isHeating: boolean) { + if (!type) { + return "config.general.customOption"; + } + const prefix = "config.charger.type."; + const suffix = isHeating ? ".heating" : ".charging"; + if (type === ConfigType.Custom) { + return `${prefix}custom${suffix}`; + } + return `${prefix}${type}`; +} + export function createDeviceUtils(deviceType: DeviceType) { function test(id: number | undefined, data: any) { let url = `config/test/${deviceType}`; diff --git a/assets/js/components/Config/DeviceTags.vue b/assets/js/components/Config/DeviceTags.vue index c34e6edc4..f55c3a0f2 100644 --- a/assets/js/components/Config/DeviceTags.vue +++ b/assets/js/components/Config/DeviceTags.vue @@ -29,6 +29,8 @@ import formatter, { POWER_UNIT } from "@/mixins/formatter"; const NO_TRUNCATE = ["phasePowers", "phaseVoltages", "phaseCurrents"]; +const HIDDEN_TAGS = ["icon", "heating", "integratedDevice"]; + export default { name: "DeviceTags", mixins: [formatter], @@ -37,11 +39,11 @@ export default { }, computed: { entries() { - return Object.entries(this.tags).map( - ([name, { value, error, warning, muted, options }]) => { + return Object.entries(this.tags) + .filter(([name]) => !HIDDEN_TAGS.includes(name)) + .map(([name, { value, error, warning, muted, options }]) => { return { name, value, error, warning, muted, options }; - } - ); + }); }, }, methods: { diff --git a/assets/js/components/Config/LoadpointModal.vue b/assets/js/components/Config/LoadpointModal.vue index 60ba4cd07..d873d0f3e 100644 --- a/assets/js/components/Config/LoadpointModal.vue +++ b/assets/js/components/Config/LoadpointModal.vue @@ -9,11 +9,27 @@ @opened="onOpened" @close="onClose" > -
+
+ +
+
- {{ $t("config.loadpoint.addCharger") }} + {{ addChargerLabel }}
@@ -95,7 +111,7 @@ tabindex="0" @click="editMeter" > - {{ $t("config.loadpoint.addMeter") }} + {{ $t(`config.loadpoint.addMeter`) }}

@@ -108,8 +124,8 @@ :label="$t('config.loadpoint.defaultModeLabel')" :help=" values.defaultMode === '' - ? $t('config.loadpoint.defaultModeHelpKeep') - : $t('config.loadpoint.defaultModeHelp') + ? $t(`config.loadpoint.defaultModeHelpKeep`) + : $t(`config.loadpoint.defaultModeHelp.${loadpointType}`) " > -
+
{{ $t("config.loadpoint.electricalTitle") }} {{ $t("config.loadpoint.electricalSubtitle") @@ -334,7 +348,11 @@ id="loadpointMinCurrent" :label="$t('config.loadpoint.minCurrentLabel')" class="col-sm-6 mb-sm-0" - :help="values.minCurrent < 6 ? $t('config.loadpoint.minCurrentHelp') : null" + :help=" + values.minCurrent < 6 + ? $t('config.loadpoint.minCurrentHelp') + : undefined + " > @@ -400,7 +413,6 @@ -
{{ $t("config.loadpoint.vehiclesTitle") }}
+
+
{{ $t("config.loadpoint.vehiclesTitle") }}
-
- - + - + :label="$t('config.loadpoint.vehicleLabel')" + :help=" + values.vehicle + ? $t('config.loadpoint.vehicleHelpDefault') + : $t('config.loadpoint.vehicleHelpAutoDetection') + " + > + + - - - - - + + + - + class="ms-3 mb-5" + :label="$t('config.loadpoint.pollIntervalLabel')" + :help="$t('config.loadpoint.pollIntervalHelp')" + :danger="$t('config.loadpoint.pollIntervalDanger')" + > + + -
- - +
+
+ + +
+
+
+
+

{{ $t("config.loadpoint.noVehicles") }}

-
-

{{ $t("config.loadpoint.noVehicles") }}

-
- -