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