-
{{ $t("config.section.general") }}
-
+
+
+
+
+
+
-
-
{{ $t("config.section.loadpoints") }}
-
-
-
-
-
-
-
-
-
-
+
+
+
-
-
+
+
+
+
+
+
+
+
+
+
+
-
{{ $t("config.section.vehicles") }}
-
-
-
-
-
-
-
-
-
-
-
+
+
+
-
{{ $t("config.section.consumers") }}
-
- openModal('meter', { type, id })"
- @enable="handleDisable('meter', meter.id, false)"
- />
- openModal('meter', { type, id })"
- @enable="handleDisable('meter', meter.id, false)"
- />
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
-
{{ $t("config.section.grid") }}
-
- openModal('meter', { type, id })"
- @enable="handleDisable('meter', gridMeter.id, false)"
- />
-
-
-
{{ $t("config.section.meter") }}
-
- openModal('meter', { type, id })"
- @enable="handleDisable('meter', meter.id, false)"
- />
- openModal('meter', { type, id })"
- @enable="handleDisable('meter', meter.id, false)"
- />
-
-
+
+
+ openModal('meter', { type, id })"
+ @enable="handleDisable('meter', meter.id, false)"
+ />
+ openModal('meter', { type, id })"
+ @enable="handleDisable('meter', meter.id, false)"
+ />
+
+
+
-
{{ $t("config.section.additionalMeter") }}
-
- openModal('meter', { type, id })"
- @enable="handleDisable('meter', meter.id, false)"
- />
-
-
+
+
+ openModal('meter', { type, id })"
+ @enable="handleDisable('meter', gridMeter.id, false)"
+ />
+
+
+
-
{{ $t("config.tariff.title") }}
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+ openModal('meter', { type, id })"
+ @enable="handleDisable('meter', meter.id, false)"
+ />
+ openModal('meter', { type, id })"
+ @enable="handleDisable('meter', meter.id, false)"
+ />
+
+
+
-
{{ $t("config.section.integrations") }}
+
+
+ openModal('meter', { type, id })"
+ @enable="handleDisable('meter', meter.id, false)"
+ />
+
+
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- {{ hemsLabel }}
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
{{ $t("config.section.services") }}
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ hemsLabel }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
{{ $t("config.section.system") }}
-
-
- {{ $t("config.system.logs") }}
-
-
- {{ $t("help.issueButton") }}
-
-
+
+
+
- {{ $t("config.system.backupRestore.title") }}
-
-
-
+
+ {{ $t("config.system.logs") }}
+
+
+ {{ $t("help.issueButton") }}
+
+
+
+
+
>;
+// section slug (anchor/deep link) -> title i18n key, in display order
+const SECTION_TITLES: Record = {
+ general: "config.section.general",
+ loadpoints: "config.section.loadpoints",
+ vehicles: "config.section.vehicles",
+ consumers: "config.section.consumers",
+ grid: "config.section.grid",
+ "pv-battery": "config.section.meter",
+ meters: "config.section.additionalMeter",
+ tariffs: "config.tariff.title",
+ integrations: "config.section.integrations",
+ services: "config.section.services",
+ system: "config.section.system",
+};
+
type DeviceTags = Record<
string,
{ value?: any; error?: boolean; warning?: boolean; muted?: boolean; options?: any }
@@ -634,6 +685,8 @@ export default defineComponent({
NewDeviceButton,
BackupRestoreModal,
ChargerModal,
+ ConfigSection,
+ ConfigSectionNav,
CircuitsIcon,
CircuitsModal,
CircuitTags,
@@ -688,7 +741,7 @@ export default defineComponent({
ApiKeyModal,
AuthProvidersCard,
},
- mixins: [formatter, collector],
+ mixins: [formatter, collector, listDetail],
props: {
offline: Boolean,
notifications: { type: Array as PropType, default: () => [] },
@@ -737,6 +790,134 @@ export default defineComponent({
return { title: this.$t("config.main.title") };
},
computed: {
+ activeSlug(): string | undefined {
+ const slug = this.$route.hash.slice(1);
+ return SECTION_TITLES[slug] ? slug : undefined;
+ },
+ headerTitle(): string {
+ return this.mobile && this.activeSlug
+ ? this.$t(SECTION_TITLES[this.activeSlug]!)
+ : this.$t("config.main.title");
+ },
+ headerParentTitle(): string {
+ return this.mobile && this.activeSlug ? this.$t("config.main.title") : "";
+ },
+ sectionEntries(): SectionEntry[] {
+ const meterError = (meters: ConfigMeter[]) =>
+ meters.some((m) => this.hasDeviceError("meter", m.name));
+ const meterDisabled = (meters: ConfigMeter[]) => meters.some((m) => m.deviceDisable);
+ const auxAndConsumer = [...this.consumerMeters, ...this.auxMeters];
+ const pvAndBattery = [...this.pvMeters, ...this.batteryMeters];
+ const configuredTariffCount =
+ [
+ this.gridTariff,
+ this.feedInTariff,
+ this.co2Tariff,
+ this.temperatureTariff,
+ this.plannerTariff,
+ ].filter(Boolean).length + this.solarTariffs.length;
+ // key = config..title i18n stem and fatal error class (see errorClass exceptions)
+ const integrationTags: Record = {
+ mqtt: this.mqttTags,
+ messaging: this.messagingTags,
+ influx: this.influxTags,
+ circuits: this.circuitsTags,
+ hems: this.hemsTags,
+ modbusproxy: this.modbusproxyTags,
+ remote: this.remoteTags,
+ optimizer: this.optimizerTags,
+ };
+ const errorClass: Record = {
+ messaging: "messenger",
+ circuits: "circuit",
+ };
+ const integrations = Object.entries(integrationTags).map(([key, tags]) => ({
+ key,
+ error: errorClass[key] || key,
+ configured: !this.isUnconfigured(tags),
+ }));
+ const entries: Omit[] = [
+ {
+ slug: "general",
+ icon: "shopicon-regular-settings",
+ subline: this.siteTitle || undefined,
+ error: this.hasClassError("sponsorship"),
+ warning: !!this.sponsor?.status?.expiresSoon,
+ },
+ {
+ slug: "loadpoints",
+ icon: markRaw(LoadpointIcon),
+ count: this.loadpoints.length,
+ error: this.loadpoints.some((lp) => this.loadpointError(lp)),
+ warning: this.loadpoints.some((lp) => lp.disable),
+ },
+ {
+ slug: "vehicles",
+ icon: "shopicon-regular-car3",
+ count: this.vehicles.length,
+ error: this.vehicles.some((v) => this.hasDeviceError("vehicle", v.name)),
+ warning: this.vehicles.some((v) => v.deviceDisable),
+ },
+ {
+ slug: "consumers",
+ icon: markRaw(GenericIcon),
+ count: auxAndConsumer.length,
+ error: meterError(auxAndConsumer),
+ warning: meterDisabled(auxAndConsumer),
+ },
+ {
+ slug: "grid",
+ icon: "shopicon-regular-powersupply",
+ count: this.gridMeter ? 1 : 0,
+ error: !!this.gridMeter && this.hasDeviceError("meter", this.gridMeter.name),
+ },
+ {
+ slug: "pv-battery",
+ icon: "shopicon-regular-sun",
+ count: pvAndBattery.length,
+ error: meterError(pvAndBattery),
+ warning:
+ this.pvMeters.some((m) => this.meterBanner(m.name)) ||
+ meterDisabled(pvAndBattery),
+ },
+ {
+ slug: "meters",
+ icon: markRaw(MeterIcon),
+ count: this.extMeters.length,
+ error: meterError(this.extMeters),
+ warning: meterDisabled(this.extMeters),
+ },
+ {
+ slug: "tariffs",
+ icon: "shopicon-regular-receivepayment",
+ count: this.tariffsYamlSource ? 1 : configuredTariffCount,
+ error:
+ this.hasClassError("tariff") ||
+ this.tariffs.some((t) => this.hasDeviceError("tariff", t.name)),
+ },
+ {
+ slug: "integrations",
+ icon: markRaw(IntegrationsIcon),
+ subline:
+ integrations
+ .filter((i) => i.configured)
+ .map((i) => this.$t(`config.${i.key}.title`))
+ .join(" · ") || undefined,
+ error: integrations.some((i) => this.hasClassError(i.error)),
+ warning: Object.values(this.authProviders || {}).some((p) => !p.authenticated),
+ },
+ {
+ slug: "services",
+ icon: markRaw(ServicesIcon),
+ error: ["ocpp", "shm", "eebus", "mcp"].some((c) => this.hasClassError(c)),
+ },
+ {
+ slug: "system",
+ icon: markRaw(SystemIcon),
+ },
+ ];
+ return entries.map((e) => ({ ...e, title: this.$t(SECTION_TITLES[e.slug]!) }));
+ },
callbackCompleted() {
return this.$route.query["callbackCompleted"] as string | undefined;
},
@@ -969,6 +1150,9 @@ export default defineComponent({
}
return { configured: { value: false } };
},
+ circuitsTags(): DeviceTags {
+ return this.circuitsRoot ? {} : { configured: { value: false } };
+ },
// maps an OCPP station id to its loadpoint title (fallback: charger title)
stationTitles(): Record {
const map: Record = {};
@@ -1056,6 +1240,17 @@ export default defineComponent({
isUnconfigured(tags: DeviceTags): boolean {
return tags["configured"]?.value === false;
},
+ sectionProps(slug: string) {
+ return {
+ slug,
+ title: this.$t(SECTION_TITLES[slug]!),
+ mobile: this.mobile,
+ active: this.mobile && this.activeSlug === slug,
+ };
+ },
+ openSection(slug: string) {
+ this.$router.push({ path: "/config", hash: `#${slug}` });
+ },
async handleDisable(deviceClass: DeviceType, id: number, disable: boolean) {
const promptKey = disable
? "config.general.confirmDisable"
@@ -1341,6 +1536,13 @@ export default defineComponent({
return { ...chargerTags, ...meterTags };
},
openModal,
+ loadpointError(loadpoint: ConfigLoadpoint): boolean {
+ return (
+ this.hasDeviceError("loadpoint", loadpoint.name) ||
+ this.hasDeviceError("charger", loadpoint.charger) ||
+ this.hasDeviceError("meter", loadpoint.meter)
+ );
+ },
hasDeviceError(type: DeviceType, name?: string) {
if (!name) return false;
const fatals = store.state?.fatal || [];
@@ -1363,6 +1565,10 @@ export default defineComponent({
}) as any;
diff --git a/i18n/de.json b/i18n/de.json
index d2b121883..2b6a02b83 100644
--- a/i18n/de.json
+++ b/i18n/de.json
@@ -1175,9 +1175,12 @@
}
},
"general": {
+ "back": "Zurück",
"download": "Download",
+ "error": "Fehler",
"none": "keine",
- "note": "Hinweis:"
+ "note": "Hinweis:",
+ "warning": "Warnung"
},
"header": {
"about": "Über",
diff --git a/i18n/en.json b/i18n/en.json
index d95423cb2..09ae539b0 100644
--- a/i18n/en.json
+++ b/i18n/en.json
@@ -842,8 +842,8 @@
"general": "General",
"grid": "Grid",
"integrations": "Integrations",
- "loadpoints": "Charging points & Heaters",
- "meter": "Solar & Battery",
+ "loadpoints": "Charging points & heaters",
+ "meter": "Solar & battery",
"services": "Services",
"system": "System",
"vehicles": "Vehicles"
@@ -977,7 +977,7 @@
"titleEdit": "Edit Temperature Forecast"
},
"template": "Provider",
- "title": "Tariffs & Forecasts",
+ "title": "Tariffs & forecasts",
"titleChoice": "What Do You Want To Add?",
"type": {
"co2": "COâ‚‚ Intensity",
@@ -1175,9 +1175,12 @@
}
},
"general": {
+ "back": "Back",
"download": "Download",
+ "error": "Error",
"none": "none",
- "note": "Note:"
+ "note": "Note:",
+ "warning": "Warning"
},
"header": {
"about": "About",
diff --git a/tests/config-mobile-nav.spec.ts b/tests/config-mobile-nav.spec.ts
new file mode 100644
index 000000000..9db0ff476
--- /dev/null
+++ b/tests/config-mobile-nav.spec.ts
@@ -0,0 +1,152 @@
+import { test, expect, devices } from "@playwright/test";
+import { start, stop, restart, baseUrl } from "./evcc";
+import { expectModalHidden, expectModalVisible, newLoadpoint, addDemoCharger } from "./utils";
+
+test.use({ baseURL: baseUrl(), viewport: devices["iPhone 12 Mini"].viewport });
+test.describe.configure({ mode: "parallel" });
+
+const desktop = devices["Desktop Chrome"].viewport;
+
+test.describe("mobile section navigation", async () => {
+ test.beforeAll(async () => {
+ await start();
+ });
+ test.afterAll(async () => {
+ await stop();
+ });
+ test("shows section list instead of long page", async ({ page }) => {
+ await page.goto("/#/config");
+ const nav = page.getByTestId("config-section-nav");
+ await expect(nav).toBeVisible();
+ await expect(nav.getByRole("button", { name: "Vehicles" })).toBeVisible();
+ await expect(page.getByRole("heading", { name: "Vehicles" })).not.toBeVisible();
+ });
+
+ test("open and close section detail", async ({ page }) => {
+ await page.goto("/#/config");
+ await page.getByRole("button", { name: "Vehicles" }).click();
+
+ const detail = page.getByTestId("section-detail-vehicles");
+ await expect(detail).toBeVisible();
+ await page.waitForURL("**/#/config#vehicles");
+ await expect(detail.getByTestId("add-vehicle")).toBeVisible();
+
+ await page.getByRole("button", { name: "Back" }).click();
+ await expect(detail).not.toBeVisible();
+ await page.waitForURL("**/#/config");
+ await expect(page.getByTestId("config-section-nav")).toBeVisible();
+ });
+
+ test("browser back closes section detail", async ({ page }) => {
+ await page.goto("/#/config");
+ await page.getByRole("button", { name: "Grid" }).click();
+ await expect(page.getByTestId("section-detail-grid")).toBeVisible();
+
+ await page.goBack();
+ await expect(page.getByTestId("section-detail-grid")).not.toBeVisible();
+ await expect(page.getByTestId("config-section-nav")).toBeVisible();
+ });
+
+ test("deep link opens section detail directly", async ({ page }) => {
+ await page.goto("/#/config#tariffs");
+ const detail = page.getByTestId("section-detail-tariffs");
+ await expect(detail).toBeVisible();
+
+ // back button must not leave the app
+ await page.getByRole("button", { name: "Back" }).click();
+ await expect(detail).not.toBeVisible();
+ await page.waitForURL("**/#/config");
+ });
+
+ test("modal keeps section detail open", async ({ page }) => {
+ await page.goto("/#/config#vehicles");
+ const detail = page.getByTestId("section-detail-vehicles");
+ await expect(detail).toBeVisible();
+
+ await detail.getByTestId("add-vehicle").click();
+ const vehicleModal = page.getByTestId("vehicle-modal");
+ await expectModalVisible(vehicleModal);
+ expect(page.url()).toContain("vehicle");
+ expect(page.url()).toContain("#vehicles");
+
+ await vehicleModal.getByRole("button", { name: "Cancel" }).click();
+ await expectModalHidden(vehicleModal);
+ await expect(detail).toBeVisible();
+ });
+
+ test("modal deep link with section hash survives reload", async ({ page }) => {
+ await page.goto("/#/config#integrations");
+ const detail = page.getByTestId("section-detail-integrations");
+ await expect(detail).toBeVisible();
+
+ await detail.getByTestId("mqtt").getByRole("button", { name: "edit" }).click();
+ const mqttModal = page.getByTestId("mqtt-modal");
+ await expectModalVisible(mqttModal);
+
+ await page.reload();
+ await expectModalVisible(mqttModal);
+ await mqttModal.getByRole("button", { name: "Cancel" }).click();
+ await expectModalHidden(mqttModal);
+ await expect(detail).toBeVisible();
+ });
+
+ test("desktop keeps long page and scrolls to anchor", async ({ page }) => {
+ await page.setViewportSize(desktop);
+ await page.goto("/#/config#tariffs");
+
+ await expect(page.getByTestId("config-section-nav")).not.toBeVisible();
+ await expect(page.getByTestId("section-detail-tariffs")).not.toBeVisible();
+ const heading = page.getByRole("heading", { name: "Tariffs & forecasts" });
+ await expect(heading).toBeInViewport();
+ });
+
+ test("resize switches between list and long page", async ({ page }) => {
+ await page.goto("/#/config#grid");
+ await expect(page.getByTestId("section-detail-grid")).toBeVisible();
+
+ await page.setViewportSize(desktop);
+ await expect(page.getByTestId("section-detail-grid")).not.toBeVisible();
+ // level 2: the header title briefly shows "Grid" while its swap transition runs
+ await expect(page.getByRole("heading", { name: "Grid", level: 2 })).toBeVisible();
+
+ await page.setViewportSize(devices["iPhone 12 Mini"].viewport);
+ await expect(page.getByTestId("section-detail-grid")).toBeVisible();
+ });
+});
+
+test.describe("section indicators", async () => {
+ test.afterEach(async () => {
+ await stop();
+ });
+
+ test("device counts", async ({ page }) => {
+ await start("config-with-vehicle.evcc.yaml");
+ await page.goto("/#/config");
+
+ const nav = page.getByTestId("config-section-nav");
+ await expect(nav.getByRole("button", { name: "Charging points & heaters" })).toContainText("1");
+ await expect(nav.getByRole("button", { name: "Vehicles" })).toContainText("1");
+ await expect(nav.getByRole("button", { name: "Grid" })).toContainText("1");
+ });
+
+ test("error indicator on fatal device error", async ({ page }) => {
+ await start("config-invalid-references-vehicle.evcc.yaml");
+ await page.goto("/#/config#loadpoints");
+
+ // loadpoint referencing a vehicle that disappears after restart
+ await newLoadpoint(page, "Garage");
+ await addDemoCharger(page);
+ const lpModal = page.getByTestId("loadpoint-modal");
+ await lpModal.getByRole("link", { name: "Advanced configuration" }).click();
+ await lpModal.getByLabel("Default vehicle").selectOption("Legacy Vehicle");
+ await lpModal.getByRole("button", { name: "Save" }).click();
+ await expectModalHidden(lpModal);
+
+ await restart();
+ await page.goto("/#/config");
+
+ await expect(page.getByTestId("fatal-error")).toBeVisible();
+ const row = page.getByRole("button", { name: "Charging points & heaters" });
+ await expect(row.getByTestId("section-error")).toBeVisible();
+ });
+});
diff --git a/tests/config-tariffs.spec.ts b/tests/config-tariffs.spec.ts
index 39b695594..3af69097d 100644
--- a/tests/config-tariffs.spec.ts
+++ b/tests/config-tariffs.spec.ts
@@ -26,7 +26,7 @@ test.describe("tariffs", async () => {
await page.goto("/#/config");
// New configuration section should show with "Add Tariff" button
- await expect(page.getByRole("heading", { name: "Tariffs & Forecasts" })).toBeVisible();
+ await expect(page.getByRole("heading", { name: "Tariffs & forecasts" })).toBeVisible();
await expect(page.getByRole("button", { name: "Add Tariff" })).toBeVisible();
// Old tariff card should not be shown
@@ -97,7 +97,7 @@ grid:
await expect(page.getByTestId("tariffs-legacy")).toBeVisible();
await expect(page.getByTestId("tariffs-legacy")).toContainText(
[
- "Tariffs & Forecasts",
+ "Tariffs & forecasts",
"Grid price",
"30.0 öre",
"Feed-in price",