diff --git a/assets/js/components/ChargingPlans/ChargingPlan.vue b/assets/js/components/ChargingPlans/ChargingPlan.vue index d268ab4e2..3750c1cc9 100644 --- a/assets/js/components/ChargingPlans/ChargingPlan.vue +++ b/assets/js/components/ChargingPlans/ChargingPlan.vue @@ -28,93 +28,26 @@ - - - - - - - - {{ $t("main.chargingPlan.departureTab") }} - - - - - {{ $t("main.chargingPlan.arrivalTab") }} - - - - - - - - - - diff --git a/assets/js/components/ChargingPlans/ChargingPlanModal.vue b/assets/js/components/ChargingPlans/ChargingPlanModal.vue new file mode 100644 index 000000000..03c5e817b --- /dev/null +++ b/assets/js/components/ChargingPlans/ChargingPlanModal.vue @@ -0,0 +1,226 @@ + + + + + + + + {{ $t("main.chargingPlan.departureTab") }} + + + + + {{ $t("main.chargingPlan.arrivalTab") }} + + + + + + + + + + + + + diff --git a/assets/js/components/Loadpoints/Loadpoint.vue b/assets/js/components/Loadpoints/Loadpoint.vue index 22296a37c..c4db30a5a 100644 --- a/assets/js/components/Loadpoints/Loadpoint.vue +++ b/assets/js/components/Loadpoints/Loadpoint.vue @@ -31,14 +31,6 @@ /> -
+
{{ $t("main.loadpointSettings.phasesConfigured.no1p3pSupport") }} @@ -60,7 +61,7 @@ type="radio" :name="formId('phases')" :value="phases" - @change="changePhasesConfigured" + @change="setPhasesConfigured" /> {{ $t(`main.loadpointSettings.phasesConfigured.phases_${phases}`) }} @@ -89,7 +90,7 @@ :id="formId('maxcurrent')" v-model.number="selectedMaxCurrent" class="form-select form-select-sm" - @change="changeMaxCurrent" + @change="setMaxCurrent" > , default: null }, + loadpoints: { type: Array as PropType, default: () => [] }, + batteryConfigured: Boolean, smartCostType: String as PropType, smartCostAvailable: Boolean, - smartFeedInPriorityLimit: { type: Number as PropType, default: null }, smartFeedInPriorityAvailable: Boolean, tariffGrid: Number, currency: String as PropType, multipleLoadpoints: Boolean, forecast: Object as PropType, - lastSmartCostLimit: Number, - lastSmartFeedInPriorityLimit: Number, }, - emits: [ - "phasesconfigured-updated", - "maxcurrent-updated", - "mincurrent-updated", - "batteryboostlimit-updated", - ], data() { return { - selectedMaxCurrent: this.maxCurrent, - selectedMinCurrent: this.minCurrent, - selectedPhases: this.phasesConfigured, + id: undefined as string | undefined, + selectedMaxCurrent: undefined as number | undefined, + selectedMinCurrent: undefined as number | undefined, + selectedPhases: undefined as number | undefined, isModalVisible: false, }; }, computed: { + loadpoint() { + return this.loadpoints.find((loadpoint) => loadpoint.id === this.id); + }, + maxCurrent() { + return this.loadpoint?.maxCurrent; + }, + minCurrent() { + return this.loadpoint?.minCurrent; + }, + batteryBoostLimit() { + return this.loadpoint?.batteryBoostLimit; + }, + phasesConfigured() { + return this.loadpoint?.phasesConfigured; + }, phasesOptions() { - if (this.chargerSinglePhase) { + if (this.loadpoint?.chargerSinglePhase) { return []; } - if (this.chargerPhases1p3p) { + if (this.loadpoint?.chargerPhases1p3p) { // automatic switching return [AUTO, THREE_PHASES, ONE_PHASE]; } @@ -215,33 +214,32 @@ export default defineComponent({ batteryBoostProps() { return this.collectProps(SettingsBatteryBoost); }, - maxPhases(): number { - if (this.chargerPhases1p3p && this.phasesConfigured === AUTO) { + maxPhases() { + if (this.loadpoint?.chargerPhases1p3p && this.phasesConfigured === AUTO) { return THREE_PHASES; } return this.phasesConfigured; }, - minPhases(): number { - if (this.chargerPhases1p3p && this.phasesConfigured === AUTO) { + minPhases() { + if (this.loadpoint?.chargerPhases1p3p && this.phasesConfigured === AUTO) { return ONE_PHASE; } return this.phasesConfigured; }, minCurrentOptions() { - const opt1 = [...range(Math.floor(this.maxCurrent), 1), 0.5, 0.25, 0.125]; + const opt1 = [...range(Math.floor(this.maxCurrent ?? 0), 1), 0.5, 0.25, 0.125]; // ensure that current value is always included - const opt2 = insertSorted(opt1, this.minCurrent); + const opt2 = insertSorted(opt1, this.minCurrent ?? 0); return opt2.map((value) => this.currentOption(value, value === 6, this.minPhases)); }, maxCurrentOptions() { - const opt1 = range(MAX_CURRENT, Math.ceil(this.minCurrent)); + const opt1 = range(MAX_CURRENT, Math.ceil(this.minCurrent ?? 0)); // ensure that current value is always included - const opt2 = insertSorted(opt1, this.maxCurrent); + const opt2 = insertSorted(opt1, this.maxCurrent ?? 0); return opt2.map((value) => this.currentOption(value, value === 16, this.maxPhases)); }, - - loadpointId() { - return this.id; + batteryBoostAvailable() { + return this.batteryConfigured; }, }, watch: { @@ -256,22 +254,36 @@ export default defineComponent({ }, }, methods: { - fmtPhasePower(current: number, phases: PHASES) { - return this.fmtW(V * current * phases); + open(loadpointId: string) { + this.id = loadpointId; + this.selectedPhases = this.phasesConfigured; + this.selectedMaxCurrent = this.maxCurrent; + this.selectedMinCurrent = this.minCurrent; + const modalRef = this.$refs["modal"] as InstanceType | undefined; + modalRef?.open(); + }, + apiPath(func: string) { + return "loadpoints/" + this.id + "/" + func; + }, + fmtPhasePower(current?: number, phases?: PHASES) { + return this.fmtW(V * (current || 0) * (phases || 0)); }, formId(name: string) { return `loadpoint_${this.id}_${name}`; }, - changeMaxCurrent() { - this.$emit("maxcurrent-updated", this.selectedMaxCurrent); + setMaxCurrent() { + api.post(this.apiPath("maxcurrent") + "/" + this.selectedMaxCurrent); }, - changeMinCurrent() { - this.$emit("mincurrent-updated", this.selectedMinCurrent); + setMinCurrent() { + api.post(this.apiPath("mincurrent") + "/" + this.selectedMinCurrent); }, - changePhasesConfigured() { - this.$emit("phasesconfigured-updated", this.selectedPhases); + setPhasesConfigured() { + api.post(this.apiPath("phases") + "/" + this.selectedPhases); }, - currentOption(current: number, isDefault: boolean, phases: number) { + setBatteryBoostLimit(limit: number) { + api.post(this.apiPath("batteryboostlimit") + "/" + limit); + }, + currentOption(current: number, isDefault: boolean, phases?: number) { const kw = this.fmtPhasePower(current, phases); let name = `${this.fmtNumber(current, undefined)} A (${kw})`; if (isDefault) { @@ -285,9 +297,6 @@ export default defineComponent({ modalInvisible() { this.isModalVisible = false; }, - changeBatteryBoostLimit(limit: number) { - this.$emit("batteryboostlimit-updated", limit); - }, }, }); diff --git a/assets/js/components/Tariff/SmartFeedInPriority.vue b/assets/js/components/Tariff/SmartFeedInPriority.vue index 370d1f7a9..e66af5bd2 100644 --- a/assets/js/components/Tariff/SmartFeedInPriority.vue +++ b/assets/js/components/Tariff/SmartFeedInPriority.vue @@ -36,7 +36,7 @@ export default defineComponent({ }, lastLimit: Number, currency: String as PropType, - loadpointId: { type: String, required: true }, + loadpointId: String, multipleLoadpoints: Boolean, possible: Boolean, tariff: Array, @@ -75,7 +75,9 @@ export default defineComponent({ await api.post(`${url}/${encodeURIComponent(limit)}`); }, saveLastLimit(limit: number) { - setLoadpointLastSmartFeedInPriorityLimit(this.loadpointId, limit); + if (this.loadpointId) { + setLoadpointLastSmartFeedInPriorityLimit(this.loadpointId, limit); + } }, async deleteLimit() { // save last selected value to be suggest again when reactivating limit diff --git a/assets/js/components/Vehicles/Vehicle.vue b/assets/js/components/Vehicles/Vehicle.vue index df0c7053c..16d108344 100644 --- a/assets/js/components/Vehicles/Vehicle.vue +++ b/assets/js/components/Vehicles/Vehicle.vue @@ -10,7 +10,7 @@ v-bind="vehicleStatus" class="mb-2" @open-loadpoint-settings="$emit('open-loadpoint-settings')" - @open-minsoc-settings="openMinSocSettings" + @open-minsoc-settings="openPlanModal(true)" @open-plan-modal="openPlanModal" /> @@ -54,6 +54,7 @@ class="flex-grow-1 target-charge" v-bind="chargingPlan" :disabled="chargingPlanDisabled" + @open-modal="$emit('open-modal')" /> + | undefined, }; }, computed: { title() { return this.vehicle?.title || ""; }, - capacity() { - return this.vehicle?.capacity || 0; - }, icon() { return this.vehicle?.icon || ""; }, @@ -230,24 +237,9 @@ export default defineComponent({ } return this.$t("main.vehicle.vehicleSoc"); }, - range() { - return distanceValue(this.vehicleRange); - }, rangeUnit() { return distanceUnit(); }, - rangePerSoc() { - if (this.vehicleSoc > 10 && this.range) { - return Math.round((this.range / this.vehicleSoc) * 1e2) / 1e2; - } - return undefined; - }, - socPerKwh() { - if (this.capacity > 0) { - return 100 / this.capacity; - } - return 0; - }, chargedSoc() { const value = this.socPerKwh * (this.chargedEnergy / 1e3); return value > 1 ? `+${this.fmtPercentage(value)}` : null; @@ -287,19 +279,12 @@ export default defineComponent({ fmtEnergy(value: number) { return this.fmtWh(value, value == 0 ? POWER_UNIT.KW : POWER_UNIT.AUTO); }, - openPlanModal() { - ( - this.$refs["chargingPlan"] as InstanceType | undefined - )?.openPlanModal(); + openPlanModal(openArrivalTab = false) { + this.$emit("open-modal", openArrivalTab); }, handleBoostStatus(status: VehicleStatus) { this.statusOverride = status; }, - openMinSocSettings() { - ( - this.$refs["chargingPlan"] as InstanceType | undefined - )?.openPlanModal(true); - }, }, }); diff --git a/assets/js/types/evcc.ts b/assets/js/types/evcc.ts index d1a9be70d..895b78527 100644 --- a/assets/js/types/evcc.ts +++ b/assets/js/types/evcc.ts @@ -325,6 +325,7 @@ export interface Loadpoint { vehicleSoc: number; vehicleTitle: string; vehicleWelcomeActive: boolean; + batteryBoostLimit: number; } export interface UiLoadpoint extends Loadpoint { @@ -336,6 +337,18 @@ export interface UiLoadpoint extends Loadpoint { visible: boolean; lastSmartCostLimit: number | undefined; lastSmartFeedInPriorityLimit: number | undefined; + range: number; + vehicleRange: number; + vehicleSoc: number; + capacity: number; + vehicleKnown: boolean; + vehicleHasSoc: boolean; + socBasedCharging: boolean; + socBasedPlanning: boolean; + sessionInfo: SessionInfoKey | undefined; + rangePerSoc: number | undefined; + socPerKwh: number; + vehicleNotReachable: boolean; } export enum THEME { diff --git a/assets/js/uiLoadpoints.ts b/assets/js/uiLoadpoints.ts index cdb4b87bc..2eab1918f 100644 --- a/assets/js/uiLoadpoints.ts +++ b/assets/js/uiLoadpoints.ts @@ -1,5 +1,6 @@ import settings from "./settings"; import type { UiLoadpoint, SessionInfoKey, Loadpoint, Vehicle } from "./types/evcc"; +import { distanceValue } from "./units"; const get = (id: string) => { if (!settings.loadpoints[id]) { @@ -15,12 +16,29 @@ export const convertToUiLoadpoints = ( if (loadpoints.length === 0) return []; const mappedLoadpoints = loadpoints.map((lp, originalIndex) => { - const vehicle = vehicles[lp.vehicleName]; + const vehicle = vehicles[lp.vehicleName] as Vehicle | undefined; const id = `${originalIndex + 1}`; + const vehicleRange = lp.vehicleRange; + const vehicleSoc = lp.vehicleSoc; + const capacity = vehicle?.capacity || 0; + const range = distanceValue(vehicleRange); + const vehicleKnown = !!lp.vehicleName; + const vehicleHasSoc = vehicleKnown && !vehicle?.features?.includes("Offline"); + const socBasedCharging = vehicleHasSoc || lp.vehicleSoc > 0; + const socBasedPlanning = !!(socBasedCharging && capacity > 0); + return { ...lp, id, + range, + vehicleRange, + vehicleSoc, + capacity, + vehicleKnown, + vehicleHasSoc, + socBasedCharging, + socBasedPlanning, displayTitle: vehicle?.title || lp.title || "Charging point", icon: lp.chargerIcon || vehicle?.icon || "car", order: getLoadpointOrder(id), @@ -28,7 +46,13 @@ export const convertToUiLoadpoints = ( sessionInfo: getLoadpointSessionInfo(id), lastSmartCostLimit: getLoadpointLastSmartCostLimit(id), lastSmartFeedInPriorityLimit: getLoadpointLastSmartFeedInPriorityLimit(id), - }; + rangePerSoc: + vehicleSoc > 10 && range ? Math.round((range / vehicleSoc) * 1e2) / 1e2 : undefined, + socPerKwh: capacity > 0 ? 100 / capacity : 0, + vehicleNotReachable: + (vehicle?.features || []).includes("Offline") && + (vehicle?.features || []).includes("Retryable"), + } satisfies UiLoadpoint; }); // Sort by order (loadpoints with no order go to the end) diff --git a/tests/boost.spec.ts b/tests/boost.spec.ts index 101266c75..bf0668185 100644 --- a/tests/boost.spec.ts +++ b/tests/boost.spec.ts @@ -25,7 +25,8 @@ test.describe("boost", async () => { await lp.getByTestId("mode").getByRole("button", { name: "Solar", exact: true }).click(); await lp.getByTestId("loadpoint-settings-button").last().click(); const modal = page.getByTestId("loadpoint-settings-modal").first(); - await modal.getByTestId("battery-boost-limit").selectOption("20 %"); + await expectModalVisible(modal); + await modal.getByLabel("Battery Boost").selectOption("20 %"); await expect(modal.getByTestId("battery-boost")).toContainText( "Allow fast charging from home battery until it's drained to 20%." ); @@ -49,7 +50,8 @@ test.describe("boost", async () => { await lp.getByTestId("mode").getByRole("button", { name: "Solar", exact: true }).click(); await lp.getByTestId("loadpoint-settings-button").last().click(); const modal = page.getByTestId("loadpoint-settings-modal").first(); - await modal.getByTestId("battery-boost-limit").selectOption("90 %"); + await expectModalVisible(modal); + await modal.getByLabel("Battery Boost").selectOption("90 %"); await expect(modal.getByTestId("battery-boost")).toContainText("drained to 90%"); await page.waitForLoadState("networkidle"); await modal.getByLabel("Close").click(); @@ -67,7 +69,8 @@ test.describe("boost", async () => { await lp.getByTestId("mode").getByRole("button", { name: "Solar", exact: true }).click(); await lp.getByTestId("loadpoint-settings-button").last().click(); const modal = page.getByTestId("loadpoint-settings-modal").first(); - await modal.getByTestId("battery-boost-limit").selectOption("20 %"); + await expectModalVisible(modal); + await modal.getByLabel("Battery Boost").selectOption("20 %"); await expect(modal.getByTestId("battery-boost")).toContainText("drained to 20%"); await page.waitForLoadState("networkidle"); await modal.getByLabel("Close").click(); @@ -87,7 +90,7 @@ test.describe("boost", async () => { await lp1.getByTestId("loadpoint-settings-button").last().click(); const modal = page.getByTestId("loadpoint-settings-modal").first(); await expectModalVisible(modal); - await modal.getByTestId("battery-boost-limit").selectOption("0 %"); + await modal.getByLabel("Battery Boost").selectOption("0 %"); await modal.getByLabel("Close").click(); await expectModalHidden(modal); @@ -135,7 +138,8 @@ test.describe("boost", async () => { // visible after setting limit await lp.getByTestId("loadpoint-settings-button").last().click(); const modal = page.getByTestId("loadpoint-settings-modal").last(); - await modal.getByTestId("battery-boost-limit").selectOption("50 %"); + await expectModalVisible(modal); + await modal.getByLabel("Battery Boost").selectOption("50 %"); await page.waitForLoadState("networkidle"); await modal.getByLabel("Close").click(); await expectModalHidden(modal); diff --git a/tests/config-ext-meter.spec.ts b/tests/config-ext-meter.spec.ts index a80e25aa9..698cd6051 100644 --- a/tests/config-ext-meter.spec.ts +++ b/tests/config-ext-meter.spec.ts @@ -207,9 +207,10 @@ test.describe("ext meter order", async () => { // Verify order in main UI consumer dropdown await page.goto("/#/"); + await expect(page.getByTestId("loadpoint")).toHaveCount(1); await page.getByTestId("energyflow").click(); await page.getByRole("button", { name: "Consumption" }).click(); - const consumers = await page.getByTestId("energyflow-entry-consumer"); + const consumers = page.getByTestId("energyflow-entry-consumer"); await expect(consumers.nth(0)).toContainText("Meter 1"); await expect(consumers.nth(1)).toContainText("Meter 2"); await expect(consumers.nth(2)).toContainText("Meter 3"); diff --git a/tests/demo.spec.ts b/tests/demo.spec.ts index ae0747367..ae085d78d 100644 --- a/tests/demo.spec.ts +++ b/tests/demo.spec.ts @@ -1,6 +1,6 @@ import { test, expect } from "@playwright/test"; import { start, stop, baseUrl } from "./evcc"; -import { expectModalHidden, openMoreMenu } from "./utils"; +import { expectModalHidden, expectModalVisible, openMoreMenu } from "./utils"; import { ChildProcess } from "child_process"; test.use({ baseURL: baseUrl() }); @@ -38,7 +38,7 @@ test.describe("demo mode", async () => { await openMoreMenu(page); await page.getByRole("link", { name: "Configuration" }).click(); const loginModal = page.getByTestId("login-modal"); - await expect(loginModal).toBeVisible(); + await expectModalVisible(loginModal); await expect(loginModal).toContainText("Login is not supported in demo mode."); }); }); diff --git a/tests/smart-cost.spec.ts b/tests/smart-cost.spec.ts index f9efe8d53..b7a359629 100644 --- a/tests/smart-cost.spec.ts +++ b/tests/smart-cost.spec.ts @@ -1,5 +1,6 @@ import { test, expect } from "@playwright/test"; import { start, stop, baseUrl } from "./evcc"; +import { expectModalHidden, expectModalVisible } from "./utils"; import { startSimulator, stopSimulator, @@ -39,12 +40,12 @@ test.describe("smart cost limit", async () => { await page.goto("/"); await page.getByTestId("loadpoint-settings-button").nth(1).click(); const modal = page.getByTestId("loadpoint-settings-modal"); - await expect(modal).toBeVisible(); + await expectModalVisible(modal); await modal.getByLabel("Enable limit").check(); await modal.getByLabel("Price limit").selectOption("≤ 40.0 ct/kWh"); await expect(modal.getByTestId("active-hours")).toHaveText(["Active time", "96 hr"].join("")); await modal.getByLabel("Close").click(); - await expect(modal).not.toBeVisible(); + await expectModalHidden(modal); await expect(page.getByTestId("vehicle-status-charger")).toHaveText("Charging…"); await expect(page.getByTestId("vehicle-status-smartcost")).toHaveText(/[24]0\.0 ct ≤ 40\.0 ct/); }); @@ -52,12 +53,12 @@ test.describe("smart cost limit", async () => { await page.goto("/"); await page.getByTestId("loadpoint-settings-button").nth(1).click(); const modal = page.getByTestId("loadpoint-settings-modal"); - await expect(modal).toBeVisible(); + await expectModalVisible(modal); await modal.getByLabel("Enable limit").check(); await modal.getByLabel("Price limit").selectOption("≤ 10.0 ct/kWh"); await expect(modal.getByTestId("active-hours")).toHaveText("Active time"); await modal.getByLabel("Close").click(); - await expect(modal).not.toBeVisible(); + await expectModalHidden(modal); await expect(page.getByTestId("vehicle-status-charger")).toHaveText("Charging…"); await expect(page.getByTestId("vehicle-status-smartcost")).toHaveText("≤ 10.0 ct"); });