From afac7ab56d5b84739bb1b33fecb61bbe2a4aedc9 Mon Sep 17 00:00:00 2001 From: Michael Geers Date: Fri, 7 Aug 2026 21:35:14 +0200 Subject: [PATCH] Config UI: fix duration template param handling (#32572) --- .../Config/DeviceModal/ModbusAdvanced.vue | 2 + .../js/components/Config/LoadpointModal.vue | 3 + assets/js/components/Config/PropertyEntry.vue | 10 +- assets/js/components/Config/PropertyField.vue | 69 ++++++++++++-- assets/js/utils/goDuration.test.ts | 76 +++++++++++++++ assets/js/utils/goDuration.ts | 48 ++++++++++ i18n/de.json | 1 + i18n/en.json | 1 + templates/definition/meter/sessy-p1.yaml | 2 +- tests/config-decimal-input.spec.ts | 4 +- tests/config-duration-demo.tpl.yaml | 25 +++++ tests/config-duration-values.sql | 23 +++++ tests/config-duration.spec.ts | 94 +++++++++++++++++++ util/templates/defaults.yaml | 1 + 14 files changed, 346 insertions(+), 13 deletions(-) create mode 100644 assets/js/utils/goDuration.test.ts create mode 100644 assets/js/utils/goDuration.ts create mode 100644 tests/config-duration-demo.tpl.yaml create mode 100644 tests/config-duration-values.sql create mode 100644 tests/config-duration.spec.ts diff --git a/assets/js/components/Config/DeviceModal/ModbusAdvanced.vue b/assets/js/components/Config/DeviceModal/ModbusAdvanced.vue index 212823c84..8193c660c 100644 --- a/assets/js/components/Config/DeviceModal/ModbusAdvanced.vue +++ b/assets/js/components/Config/DeviceModal/ModbusAdvanced.vue @@ -8,6 +8,7 @@ :id="formId('modbusTimeout')" property="timeout" type="Duration" + :label="$t('config.modbus.timeout')" class="me-2" :model-value="timeout" @update:model-value="(v) => $emit('update:timeout', v)" @@ -22,6 +23,7 @@ :id="formId('modbusDelay')" property="delay" type="Duration" + :label="$t('config.modbus.delay')" class="me-2" :model-value="delay" @update:model-value="(v) => $emit('update:delay', v)" diff --git a/assets/js/components/Config/LoadpointModal.vue b/assets/js/components/Config/LoadpointModal.vue index 3f2703bd0..b48f9d1dc 100644 --- a/assets/js/components/Config/LoadpointModal.vue +++ b/assets/js/components/Config/LoadpointModal.vue @@ -192,6 +192,7 @@ id="loadpointEnableDelay" v-model="values.thresholds.enable.delay" type="Duration" + legacy-duration unit="minute" size="w-25 w-min-200" required @@ -255,6 +256,7 @@ id="loadpointDisableDelay" v-model="values.thresholds.disable.delay" type="Duration" + legacy-duration unit="minute" size="w-25 w-min-200" required @@ -519,6 +521,7 @@ id="loadpointPollInterval" v-model="values.soc.poll.interval" type="Duration" + legacy-duration unit="minute" size="w-25 w-min-200" class="me-2" diff --git a/assets/js/components/Config/PropertyEntry.vue b/assets/js/components/Config/PropertyEntry.vue index 7ef34e012..d14c214e6 100644 --- a/assets/js/components/Config/PropertyEntry.vue +++ b/assets/js/components/Config/PropertyEntry.vue @@ -28,10 +28,13 @@ /* oxlint-disable vue/prop-name-casing */ import FormRow from "./FormRow.vue"; import PropertyField from "./PropertyField.vue"; +import formatter from "@/mixins/formatter"; +import { goDurationToUnit, goDurationUnit } from "@/utils/goDuration"; export default { name: "PropertyEntry", components: { FormRow, PropertyField }, + mixins: [formatter], props: { id: String, Name: String, @@ -66,8 +69,11 @@ export default { return this.Description === this.Help ? undefined : this.Help; }, example() { - // hide example text since config ui doesnt use go duration format (e.g. 5m) - return this.Type === "Duration" ? undefined : this.Example; + // show duration example in its own unit, field unit is user-changeable + const unit = this.Type === "Duration" ? goDurationUnit(this.Example) : null; + if (!unit) return this.Example; + const value = goDurationToUnit(this.Example, unit); + return `${value} ${this.fmtDurationUnit(value, unit)}`; }, }, }; diff --git a/assets/js/components/Config/PropertyField.vue b/assets/js/components/Config/PropertyField.vue index 3e016e0b7..b1ba72ff6 100644 --- a/assets/js/components/Config/PropertyField.vue +++ b/assets/js/components/Config/PropertyField.vue @@ -122,8 +122,23 @@ + + {{ unitValue }} + { - return { selectMode: false }; + return { selectMode: false, unitOverride: null }; }, computed: { patternRegex() { @@ -240,7 +264,7 @@ export default { }, unitValue() { if (this.type === "Duration") { - return this.fmtDurationUnit(this.value, this.unit); + return this.fmtDurationUnit(this.value, this.durationUnit); } if (this.pricePerKWh) { return this.pricePerKWhUnit(this.currency); @@ -297,7 +321,19 @@ export default { return this.choice.length > 0 || this.chargeModes; }, durationFactor() { - return this.unit === "minute" ? 60 : 1; + return displayFactors[this.durationUnit] ?? 1; + }, + durationUnit() { + return this.unitOverride ?? goDurationUnit(this.modelValue) ?? this.unit ?? "second"; + }, + unitSelectable() { + return this.type === "Duration" && !this.legacyDuration && !this.disabled; + }, + unitOptions() { + return durationUnits.map((value) => ({ + value, + name: this.fmtDurationUnit(2, value), + })); }, selectOptions() { if (this.chargeModes) { @@ -343,8 +379,15 @@ export default { return Array.isArray(this.modelValue) ? this.modelValue.join("\n") : ""; } - if (this.type === "Duration" && typeof this.modelValue === "number") { - return this.modelValue / this.durationFactor / NS_PER_SECOND; + if (this.type === "Duration") { + const ns = + typeof this.modelValue === "string" + ? parseGoDuration(this.modelValue) + : this.modelValue; + if (typeof ns === "number") { + return ns / this.durationFactor / NS_PER_SECOND; + } + return ""; } if (this.pricePerKWh) { @@ -367,7 +410,9 @@ export default { } if (this.type === "Duration" && typeof newValue === "number") { - newValue = newValue * this.durationFactor * NS_PER_SECOND; + newValue = this.legacyDuration + ? newValue * this.durationFactor * NS_PER_SECOND + : toGoDuration(newValue, this.durationUnit); } if (this.pricePerKWh) { @@ -385,6 +430,14 @@ export default { } return val; }, + onUnitChange(e) { + // read display value before override changes the getter's unit + const num = this.value; + this.unitOverride = e.target.value; + if (typeof num === "number") { + this.$emit("update:modelValue", toGoDuration(num, this.unitOverride)); + } + }, onFieldChange(e) { // unparsable input (e.g. locale decimal separator mismatch) if (e.target.validity?.badInput) return; diff --git a/assets/js/utils/goDuration.test.ts b/assets/js/utils/goDuration.test.ts new file mode 100644 index 000000000..7ceb51d14 --- /dev/null +++ b/assets/js/utils/goDuration.test.ts @@ -0,0 +1,76 @@ +import { describe, expect, test } from "vite-plus/test"; +import parseGoDuration, { goDurationToUnit, goDurationUnit, toGoDuration } from "./goDuration"; + +describe("parseGoDuration", () => { + test("parses single unit", () => { + expect(parseGoDuration("24h")).toBe(24 * 3.6e12); + expect(parseGoDuration("30m")).toBe(30 * 6e10); + expect(parseGoDuration("45s")).toBe(45e9); + expect(parseGoDuration("500ms")).toBe(5e8); + expect(parseGoDuration("10us")).toBe(1e4); + expect(parseGoDuration("10µs")).toBe(1e4); + expect(parseGoDuration("100ns")).toBe(100); + }); + + test("parses compound durations", () => { + expect(parseGoDuration("1h30m")).toBe(3.6e12 + 30 * 6e10); + expect(parseGoDuration("1m30s")).toBe(6e10 + 30e9); + }); + + test("parses decimals", () => { + expect(parseGoDuration("0.1s")).toBe(1e8); + expect(parseGoDuration("1.5h")).toBe(1.5 * 3.6e12); + }); + + test("rejects invalid input", () => { + expect(parseGoDuration("")).toBeNull(); + expect(parseGoDuration("6")).toBeNull(); + expect(parseGoDuration("1h30")).toBeNull(); + expect(parseGoDuration("abc")).toBeNull(); + expect(parseGoDuration("6 h")).toBeNull(); + }); +}); + +describe("goDurationToUnit", () => { + test("converts to display unit", () => { + expect(goDurationToUnit("10s")).toBe(10); + expect(goDurationToUnit("5m")).toBe(300); + expect(goDurationToUnit("90s", "minute")).toBe(1.5); + expect(goDurationToUnit("3h", "hour")).toBe(3); + expect(goDurationToUnit("6", "hour")).toBeNull(); + }); +}); + +describe("toGoDuration", () => { + test("formats value with unit suffix", () => { + expect(toGoDuration(6, "hour")).toBe("6h"); + expect(toGoDuration(90, "second")).toBe("90s"); + expect(toGoDuration(30, "minute")).toBe("30m"); + expect(toGoDuration(1.5, "hour")).toBe("1.5h"); + expect(toGoDuration(0.1, "second")).toBe("0.1s"); + expect(toGoDuration(0)).toBe("0s"); + expect(toGoDuration(5, "fortnight")).toBe("5s"); + }); + + test("roundtrips through parseGoDuration", () => { + expect(parseGoDuration(toGoDuration(6, "hour"))).toBe(6 * 3.6e12); + expect(parseGoDuration(toGoDuration(1.5, "minute"))).toBe(90e9); + }); +}); + +describe("goDurationUnit", () => { + test("detects single-unit strings", () => { + expect(goDurationUnit("12h")).toBe("hour"); + expect(goDurationUnit("90s")).toBe("second"); + expect(goDurationUnit("30m")).toBe("minute"); + expect(goDurationUnit("1.5h")).toBe("hour"); + }); + + test("null for composite, numbers, empty", () => { + expect(goDurationUnit("1h30m")).toBeNull(); + expect(goDurationUnit("")).toBeNull(); + expect(goDurationUnit(15000000000)).toBeNull(); + expect(goDurationUnit("6")).toBeNull(); + expect(goDurationUnit("500ms")).toBeNull(); + }); +}); diff --git a/assets/js/utils/goDuration.ts b/assets/js/utils/goDuration.ts new file mode 100644 index 000000000..de68d11b3 --- /dev/null +++ b/assets/js/utils/goDuration.ts @@ -0,0 +1,48 @@ +const factors: Record = { + ns: 1, + us: 1e3, + µs: 1e3, + ms: 1e6, + s: 1e9, + m: 6e10, + h: 3.6e12, +}; + +export const displayFactors: Record = { minute: 60, hour: 3600 }; + +export type DurationUnit = "second" | "minute" | "hour"; +type DurationSuffix = "s" | "m" | "h"; + +const unitSuffixes: Record = { second: "s", minute: "m", hour: "h" }; +const suffixUnits: Record = { s: "second", m: "minute", h: "hour" }; + +export const durationUnits = Object.keys(unitSuffixes) as DurationUnit[]; + +// display value in given Intl unit to go duration string ("6h", "90s", "1.5h") +export function toGoDuration(value: number, unit: string = "second"): string { + return `${value}${unitSuffixes[unit as DurationUnit] ?? "s"}`; +} + +// Intl unit of a single-unit go duration string ("12h" > "hour"), null for composite/number/empty +export function goDurationUnit(value: unknown): DurationUnit | null { + if (typeof value !== "string") return null; + const match = value.match(/^\d+(?:\.\d+)?(s|m|h)$/); + return match ? suffixUnits[match[1] as DurationSuffix] : null; +} + +// go duration string to display value in given Intl unit (default second), null if invalid +export function goDurationToUnit(value: string, unit?: string): number | null { + const ns = parseGoDuration(value); + return ns === null ? null : ns / (displayFactors[unit ?? ""] ?? 1) / 1e9; +} + +// go duration string ("24h", "1h30m", "0.1s") to nanoseconds, null if invalid +export default function parseGoDuration(value: string): number | null { + let total = 0; + let matched = ""; + for (const [all, num, unit] of value.matchAll(/(\d+(?:\.\d+)?)(ns|us|µs|ms|s|m|h)/g)) { + total += parseFloat(num!) * factors[unit!]!; + matched += all; + } + return matched && matched === value ? total : null; +} diff --git a/i18n/de.json b/i18n/de.json index 6c651dd52..bd29b8201 100644 --- a/i18n/de.json +++ b/i18n/de.json @@ -306,6 +306,7 @@ "form": { "danger": "Achtung", "deprecated": "veraltet", + "durationUnit": "Zeiteinheit für {label}", "example": "Beispiel", "optional": "optional" }, diff --git a/i18n/en.json b/i18n/en.json index 48a81d482..91eb2eddb 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -306,6 +306,7 @@ "form": { "danger": "Danger", "deprecated": "deprecated", + "durationUnit": "Time unit for {label}", "example": "Example", "optional": "optional" }, diff --git a/templates/definition/meter/sessy-p1.yaml b/templates/definition/meter/sessy-p1.yaml index 8fc06991b..68a56c375 100644 --- a/templates/definition/meter/sessy-p1.yaml +++ b/templates/definition/meter/sessy-p1.yaml @@ -8,7 +8,7 @@ params: choice: ["grid"] - name: host - name: cache - default: 10 + default: 10s render: | type: custom power: diff --git a/tests/config-decimal-input.spec.ts b/tests/config-decimal-input.spec.ts index 7974117d4..1ba0c8108 100644 --- a/tests/config-decimal-input.spec.ts +++ b/tests/config-decimal-input.spec.ts @@ -31,12 +31,12 @@ test("decimal input with german separator", async ({ page }) => { await modal.getByRole("button", { name: "Show advanced settings" }).click(); // comma accepted as decimal separator - const timeout = modal.getByLabel("Timeout"); + const timeout = modal.getByRole("spinbutton", { name: "Timeout" }); await timeout.pressSequentially("0,5"); await expect(timeout).toHaveValue("0.5"); // intermediate zeros survive typing - const delay = modal.getByLabel("Delay"); + const delay = modal.getByRole("spinbutton", { name: "Delay" }); await delay.pressSequentially("0.004"); await expect(delay).toHaveValue("0.004"); await delay.blur(); diff --git a/tests/config-duration-demo.tpl.yaml b/tests/config-duration-demo.tpl.yaml new file mode 100644 index 000000000..420fe1336 --- /dev/null +++ b/tests/config-duration-demo.tpl.yaml @@ -0,0 +1,25 @@ +template: duration-demo +group: generic +products: + - description: + generic: Duration Demo Meter +params: + - name: usage + choice: ["grid"] + - name: shortduration + type: duration + default: 30s + example: 10s + description: + generic: Short duration + - name: longduration + type: duration + unit: hour + default: 3h + description: + generic: Long duration +render: | + type: custom + power: + source: const + value: 0 diff --git a/tests/config-duration-values.sql b/tests/config-duration-values.sql new file mode 100644 index 000000000..7fa46fbe1 --- /dev/null +++ b/tests/config-duration-values.sql @@ -0,0 +1,23 @@ +BEGIN; + +CREATE TABLE `configs` ( + `id` integer PRIMARY KEY AUTOINCREMENT + , `class` integer + , `type` text + , `title` text + , `icon` text + , `product` text + , `value` text +); +CREATE TABLE `settings` ( + `key` text + , `value` text + , PRIMARY KEY(`key`) +); + +-- longduration as duration string (UI-written format), shortduration as legacy nanosecond number +INSERT INTO configs(id, class, type, title, icon, product, value) VALUES(1, 2, 'template', 'Duration Test', '', 'Duration Demo Meter', '{"template":"duration-demo","usage":"grid","longduration":"12h","shortduration":15000000000}'); + +INSERT INTO settings("key", value) VALUES('gridMeter', 'db:1'); + +COMMIT; diff --git a/tests/config-duration.spec.ts b/tests/config-duration.spec.ts new file mode 100644 index 000000000..e1f3f9be3 --- /dev/null +++ b/tests/config-duration.spec.ts @@ -0,0 +1,94 @@ +import { test, expect, type Page, type Locator } from "@playwright/test"; +import { start, stop, restart, baseUrl } from "./evcc"; +import { expectModalVisible, expectModalHidden } from "./utils"; + +const templateFlags = [ + "--disable-auth", + "--template-type", + "meter", + "--template", + "tests/config-duration-demo.tpl.yaml", +]; + +test.use({ baseURL: baseUrl() }); + +test.afterEach(async () => { + await stop(); +}); + +async function openGridMeterModal(page: Page): Promise { + await page.goto("/#/config"); + await page.getByTestId("grid").getByRole("button", { name: "edit" }).click(); + const modal = page.getByTestId("meter-modal"); + await expectModalVisible(modal); + return modal; +} + +test.describe("duration fields", async () => { + test("template defaults and roundtrip", async ({ page }) => { + await start(undefined, undefined, templateFlags); + await page.goto("/#/config"); + await page.getByRole("button", { name: "Add grid meter" }).click(); + const modal = page.getByTestId("meter-modal"); + await expectModalVisible(modal); + await modal.getByLabel("Manufacturer").selectOption("Duration Demo Meter"); + + await expect(modal.getByRole("spinbutton", { name: "Short duration" })).toHaveValue("30"); + await expect(modal.getByText("Example: 10 seconds", { exact: true })).toBeVisible(); + await expect(modal.getByLabel("Time unit for Short duration")).toHaveValue("second"); + await expect(modal.getByRole("spinbutton", { name: "Long duration" })).toHaveValue("3"); + await expect(modal.getByLabel("Time unit for Long duration")).toHaveValue("hour"); + + await modal.getByRole("spinbutton", { name: "Long duration" }).fill("6"); + await modal.getByRole("button", { name: "Save" }).click(); + await expectModalHidden(modal); + + await restart(undefined, templateFlags); + await page.reload(); + + // edited value and untouched default are both stored as duration strings + const reopened = await openGridMeterModal(page); + await expect(reopened.getByRole("spinbutton", { name: "Long duration" })).toHaveValue("6"); + await expect(reopened.getByRole("spinbutton", { name: "Short duration" })).toHaveValue("30"); + }); + + test("existing values as string and nanoseconds", async ({ page }) => { + await start(undefined, "config-duration-values.sql", templateFlags); + + const modal = await openGridMeterModal(page); + await expect(modal.getByRole("spinbutton", { name: "Long duration" })).toHaveValue("12"); + await expect(modal.getByRole("spinbutton", { name: "Short duration" })).toHaveValue("15"); + + await modal.getByRole("button", { name: "Save" }).click(); + await expectModalHidden(modal); + + await restart(undefined, templateFlags); + await page.reload(); + + const reopened = await openGridMeterModal(page); + await expect(reopened.getByRole("spinbutton", { name: "Long duration" })).toHaveValue("12"); + await expect(reopened.getByRole("spinbutton", { name: "Short duration" })).toHaveValue("15"); + }); + + test("unit switching keeps number", async ({ page }) => { + await start(undefined, "config-duration-values.sql", templateFlags); + + // stored "12h" drives the unit, ns number falls back to template default + const modal = await openGridMeterModal(page); + await expect(modal.getByLabel("Time unit for Long duration")).toHaveValue("hour"); + await expect(modal.getByLabel("Time unit for Short duration")).toHaveValue("second"); + + await modal.getByLabel("Time unit for Long duration").selectOption("minutes"); + await expect(modal.getByRole("spinbutton", { name: "Long duration" })).toHaveValue("12"); + await modal.getByRole("button", { name: "Save" }).click(); + await expectModalHidden(modal); + + await restart(undefined, templateFlags); + await page.reload(); + + // stored "12m" restores the selected unit + const reopened = await openGridMeterModal(page); + await expect(reopened.getByRole("spinbutton", { name: "Long duration" })).toHaveValue("12"); + await expect(reopened.getByLabel("Time unit for Long duration")).toHaveValue("minute"); + }); +}); diff --git a/util/templates/defaults.yaml b/util/templates/defaults.yaml index f392968a6..262a67f1e 100644 --- a/util/templates/defaults.yaml +++ b/util/templates/defaults.yaml @@ -489,6 +489,7 @@ params: en: Battery storage unit index de: Nummer des Batteriespeichers - name: delay + type: duration description: en: Delay de: Verzögerung