Config UI: fix duration template param handling (#32572)
This commit is contained in:
parent
6f8a6b8c36
commit
afac7ab56d
14 changed files with 346 additions and 13 deletions
|
|
@ -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)"
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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)}`;
|
||||
},
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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
|
||||
: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-if="unitValue"
|
||||
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;
|
||||
|
|
|
|||
76
assets/js/utils/goDuration.test.ts
Normal file
76
assets/js/utils/goDuration.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
48
assets/js/utils/goDuration.ts
Normal file
48
assets/js/utils/goDuration.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -306,6 +306,7 @@
|
|||
"form": {
|
||||
"danger": "Achtung",
|
||||
"deprecated": "veraltet",
|
||||
"durationUnit": "Zeiteinheit für {label}",
|
||||
"example": "Beispiel",
|
||||
"optional": "optional"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -306,6 +306,7 @@
|
|||
"form": {
|
||||
"danger": "Danger",
|
||||
"deprecated": "deprecated",
|
||||
"durationUnit": "Time unit for {label}",
|
||||
"example": "Example",
|
||||
"optional": "optional"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ params:
|
|||
choice: ["grid"]
|
||||
- name: host
|
||||
- name: cache
|
||||
default: 10
|
||||
default: 10s
|
||||
render: |
|
||||
type: custom
|
||||
power:
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
25
tests/config-duration-demo.tpl.yaml
Normal file
25
tests/config-duration-demo.tpl.yaml
Normal 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
|
||||
23
tests/config-duration-values.sql
Normal file
23
tests/config-duration-values.sql
Normal 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;
|
||||
94
tests/config-duration.spec.ts
Normal file
94
tests/config-duration.spec.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
|
|
@ -489,6 +489,7 @@ params:
|
|||
en: Battery storage unit index
|
||||
de: Nummer des Batteriespeichers
|
||||
- name: delay
|
||||
type: duration
|
||||
description:
|
||||
en: Delay
|
||||
de: Verzögerung
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue