Config UI: fix duration template param handling (#32572)

This commit is contained in:
Michael Geers 2026-08-07 21:35:14 +02:00 • committed by GitHub
parent 6f8a6b8c36
commit afac7ab56d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 346 additions and 13 deletions

View file

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

View file

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

View file

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

View file

@ -122,8 +122,23 @@
</option>
</datalist>
</div>
<CustomSelect
v-if="unitSelectable"
:id="id + '_unit_select'"
:options="unitOptions"
:selected="durationUnit"
:aria-label="$t('config.form.durationUnit', { label })"
@change="onUnitChange"
>
<span
v-if="unitValue"
:id="id + '_unit'"
class="input-group-text h-100"
style="border-top-left-radius: 0; border-bottom-left-radius: 0"
>{{ unitValue }}</span
>
</CustomSelect>
<span
v-else-if="unitValue"
:id="id + '_unit'"
class="input-group-text"
style="border-top-left-radius: 0; border-bottom-left-radius: 0"
@ -136,14 +151,21 @@
import "@h2d2/shopicons/es/regular/minus";
import VehicleIcon from "../VehicleIcon";
import SelectGroup from "../Helper/SelectGroup.vue";
import CustomSelect from "../Helper/CustomSelect.vue";
import PropertyZonesField from "./PropertyZonesField.vue";
import formatter from "@/mixins/formatter";
import parseGoDuration, {
displayFactors,
durationUnits,
goDurationUnit,
toGoDuration,
} from "@/utils/goDuration";
const NS_PER_SECOND = 1000000000;
export default {
name: "PropertyField",
components: { VehicleIcon, SelectGroup, PropertyZonesField },
components: { VehicleIcon, SelectGroup, CustomSelect, PropertyZonesField },
mixins: [formatter],
props: {
id: String,
@ -152,6 +174,8 @@ export default {
placeholder: String,
type: String,
unit: String,
// transitional: emit ns numbers, remove once all callers accept duration strings (loadpoint follow-up)
legacyDuration: Boolean,
size: String,
scale: Number,
required: Boolean,
@ -167,7 +191,7 @@ export default {
},
emits: ["update:modelValue"],
data: () => {
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;

View file

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

View file

@ -0,0 +1,48 @@
const factors: Record<string, number> = {
ns: 1,
us: 1e3,
µs: 1e3,
ms: 1e6,
s: 1e9,
m: 6e10,
h: 3.6e12,
};
export const displayFactors: Record<string, number> = { minute: 60, hour: 3600 };
export type DurationUnit = "second" | "minute" | "hour";
type DurationSuffix = "s" | "m" | "h";
const unitSuffixes: Record<DurationUnit, DurationSuffix> = { second: "s", minute: "m", hour: "h" };
const suffixUnits: Record<DurationSuffix, DurationUnit> = { 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;
}

View file

@ -306,6 +306,7 @@
"form": {
"danger": "Achtung",
"deprecated": "veraltet",
"durationUnit": "Zeiteinheit für {label}",
"example": "Beispiel",
"optional": "optional"
},

View file

@ -306,6 +306,7 @@
"form": {
"danger": "Danger",
"deprecated": "deprecated",
"durationUnit": "Time unit for {label}",
"example": "Example",
"optional": "optional"
},

View file

@ -8,7 +8,7 @@ params:
choice: ["grid"]
- name: host
- name: cache
default: 10
default: 10s
render: |
type: custom
power:

View file

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

View file

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

View file

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

View file

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

View file

@ -489,6 +489,7 @@ params:
en: Battery storage unit index
de: Nummer des Batteriespeichers
- name: delay
type: duration
description:
en: Delay
de: Verzögerung