diff --git a/assets/js/components/Battery/BatterySettingsModal.vue b/assets/js/components/Battery/BatterySettingsModal.vue index 9da8b399b..d5ca23cfb 100644 --- a/assets/js/components/Battery/BatterySettingsModal.vue +++ b/assets/js/components/Battery/BatterySettingsModal.vue @@ -245,6 +245,7 @@ v-if="isModalVisible" v-show="gridTabActive" :current-limit="batteryGridChargeLimit" + :last-limit="lastSmartCostLimit" :smart-cost-type="smartCostType" :currency="currency" :tariff="gridChargeTariff" @@ -263,6 +264,7 @@ import GenericModal from "../Helper/GenericModal.vue"; import formatter, { POWER_UNIT } from "@/mixins/formatter"; import collector from "@/mixins/collector.js"; import api from "@/api"; +import settings from "@/settings"; import { defineComponent, type PropType } from "vue"; import type { BatteryMeter, SelectOption, CURRENCY, Forecast } from "@/types/evcc"; import { SMART_COST_TYPE } from "@/types/evcc"; @@ -389,6 +391,9 @@ export default defineComponent({ return `${name}${formattedEnergy}${formattedSoc}`; }); }, + lastSmartCostLimit() { + return settings.lastBatterySmartCostLimit; + }, }, watch: { prioritySoc(soc) { diff --git a/assets/js/components/Loadpoints/Loadpoint.vue b/assets/js/components/Loadpoints/Loadpoint.vue index d9b44a97c..cc9fba3c4 100644 --- a/assets/js/components/Loadpoints/Loadpoint.vue +++ b/assets/js/components/Loadpoints/Loadpoint.vue @@ -24,6 +24,7 @@ , + lastSmartCostLimit: Number, + lastSmartFeedInPriorityLimit: Number, }, data() { return { diff --git a/assets/js/components/Loadpoints/SettingsModal.vue b/assets/js/components/Loadpoints/SettingsModal.vue index 8e4cfab2b..920a8c934 100644 --- a/assets/js/components/Loadpoints/SettingsModal.vue +++ b/assets/js/components/Loadpoints/SettingsModal.vue @@ -10,10 +10,11 @@
, multipleLoadpoints: Boolean, forecast: Object as PropType, + lastSmartCostLimit: Number, + lastSmartFeedInPriorityLimit: Number, }, emits: [ "phasesconfigured-updated", diff --git a/assets/js/components/Tariff/SmartCostLimit.vue b/assets/js/components/Tariff/SmartCostLimit.vue index 1ee997f75..e04f9fe3c 100644 --- a/assets/js/components/Tariff/SmartCostLimit.vue +++ b/assets/js/components/Tariff/SmartCostLimit.vue @@ -2,6 +2,7 @@ , multipleLoadpoints: Boolean, isLoadpoint: Boolean, - loadpointId: Number, + loadpointId: String, possible: Boolean, + lastLimit: Number, tariff: Array as PropType, }, computed: { @@ -69,18 +73,27 @@ export default defineComponent({ // Smart cost: charge when costs are below or equal to limit return value <= this.currentLimit; }, - async saveLimit(limit: string) { + async saveLimit(limit: number) { + // save last selected value to be suggest again when reactivating limit + this.saveLastLimit(limit); + const url = this.isLoadpoint ? `loadpoints/${this.loadpointId}/smartcostlimit` : "batterygridchargelimit"; - if (limit === "null") { - await api.delete(url); + await api.post(`${url}/${encodeURIComponent(limit)}`); + }, + saveLastLimit(limit: number) { + if (this.isLoadpoint) { + setLoadpointLastSmartCostLimit(this.loadpointId!, limit); } else { - await api.post(`${url}/${encodeURIComponent(limit)}`); + settings.lastBatterySmartCostLimit = limit; } }, async deleteLimit() { + // save last selected value to be suggest again when reactivating limit + this.saveLastLimit(this.currentLimit || 0); + const url = this.isLoadpoint ? `loadpoints/${this.loadpointId}/smartcostlimit` : "batterygridchargelimit"; diff --git a/assets/js/components/Tariff/SmartFeedInPriority.vue b/assets/js/components/Tariff/SmartFeedInPriority.vue index 4f1b0896e..4a1ffc5af 100644 --- a/assets/js/components/Tariff/SmartFeedInPriority.vue +++ b/assets/js/components/Tariff/SmartFeedInPriority.vue @@ -2,6 +2,7 @@ , required: true, }, + lastLimit: Number, currency: String as PropType, - loadpointId: { type: Number, required: true }, + loadpointId: { type: String, required: true }, multipleLoadpoints: Boolean, possible: Boolean, tariff: Array, @@ -62,16 +65,20 @@ export default defineComponent({ // Smart feed-in priority: pause when rates are above or equal to limit return value >= this.currentLimit; }, - async saveLimit(limit: string) { - const url = `loadpoints/${this.loadpointId}/smartfeedinprioritylimit`; + async saveLimit(limit: number) { + // save last selected value to be suggest again when reactivating limit + this.saveLastLimit(limit); - if (limit === "null") { - await api.delete(url); - } else { - await api.post(`${url}/${encodeURIComponent(limit)}`); - } + const url = `loadpoints/${this.loadpointId}/smartfeedinprioritylimit`; + await api.post(`${url}/${encodeURIComponent(limit)}`); + }, + saveLastLimit(limit: number) { + setLoadpointLastSmartFeedInPriorityLimit(this.loadpointId, limit); }, async deleteLimit() { + // save last selected value to be suggest again when reactivating limit + this.saveLastLimit(this.currentLimit || 0); + const url = `loadpoints/${this.loadpointId}/smartfeedinprioritylimit`; await api.delete(url); }, diff --git a/assets/js/components/Tariff/SmartTariffBase.vue b/assets/js/components/Tariff/SmartTariffBase.vue index 6584ab521..995a4369a 100644 --- a/assets/js/components/Tariff/SmartTariffBase.vue +++ b/assets/js/components/Tariff/SmartTariffBase.vue @@ -4,22 +4,38 @@

{{ description }}

-
+
- +
+
+
+ +
+
+ +
-
+
{{ activeHoursLabel }}
@@ -91,6 +107,7 @@ export default defineComponent({ type: [Number, null] as PropType, required: true, }, + lastLimit: { type: Number, default: 0 }, isCo2: Boolean, currency: String as PropType, applyAll: Boolean, @@ -118,6 +135,7 @@ export default defineComponent({ selectedLimit: null as number | null, activeIndex: null as number | null, applyToAllVisible: false, + active: false, }; }, computed: { @@ -136,7 +154,7 @@ export default defineComponent({ const values = [] as number[]; const stepSize = this.optionStepSize; - for (let i = 1; i <= 100; i++) { + for (let i = 0; i <= 100; i++) { const value = this.optionStartValue + stepSize * i; if (max !== undefined && value > max + stepSize) break; values.push(this.roundLimit(value) as number); @@ -204,14 +222,14 @@ export default defineComponent({ const value = this.findRateInRange(start, end, rates)?.value; const active = this.limitDirection === "below" && - this.selectedLimit !== null && + this.currentLimit !== null && value !== undefined && - value <= this.selectedLimit; + value <= this.currentLimit; const warning = this.limitDirection === "above" && - this.selectedLimit !== null && + this.currentLimit !== null && value !== undefined && - value >= this.selectedLimit; + value >= this.currentLimit; return { day: this.weekdayShort(start), @@ -275,17 +293,26 @@ export default defineComponent({ }, }, watch: { - currentLimit(limit) { - this.selectedLimit = this.roundLimit(limit); + currentLimit() { + this.initLimit(); }, }, mounted() { - this.selectedLimit = this.roundLimit(this.currentLimit); + this.initLimit(); }, methods: { roundLimit(limit: number | null): number | null { return limit === null ? null : Math.round(limit * 1000) / 1000; }, + initLimit() { + if (this.currentLimit === null) { + this.active = false; + this.selectedLimit = this.lastLimit; + } else { + this.active = true; + this.selectedLimit = this.roundLimit(this.currentLimit); + } + }, formatLimit(limit: number | null): string { if (limit === null) { return this.$t("smartCost.none"); @@ -338,18 +365,27 @@ export default defineComponent({ // 3 decimal precision const valueRounded = Math.ceil(value * 1000) / 1000; this.selectedLimit = valueRounded; - this.saveLimit(`${valueRounded}`); + this.active = true; + this.saveLimit(valueRounded); } }, changeLimit($event: Event) { - const value = ($event.target as HTMLSelectElement).value; - if (value === "null") { - this.resetLimit(); + const value = parseFloat(($event.target as HTMLSelectElement).value); + this.saveLimit(value); + }, + toggleActive($event: Event) { + const active = ($event.target as HTMLInputElement).checked; + if (active) { + this.saveLimit(this.lastLimit); } else { - this.saveLimit(value); + this.resetLimit(); + } + this.active = active; + if (this.applyAll) { + this.applyToAllVisible = true; } }, - saveLimit(limit: string) { + saveLimit(limit: number) { this.$emit("save-limit", limit); if (this.applyAll) { this.applyToAllVisible = true; @@ -362,7 +398,7 @@ export default defineComponent({ } }, applyToAll() { - this.$emit("apply-to-all", this.selectedLimit); + this.$emit("apply-to-all", this.currentLimit); this.applyToAllVisible = false; }, }, diff --git a/assets/js/settings.ts b/assets/js/settings.ts index 6cba5e540..93eb77c84 100644 --- a/assets/js/settings.ts +++ b/assets/js/settings.ts @@ -19,6 +19,7 @@ const SAVINGS_REGION = "savings_region"; const SESSIONS_GROUP = "sessions_group"; const SESSIONS_TYPE = "sessions_type"; const SETTINGS_SOLAR_ADJUSTED = "settings_solar_adjusted"; +const LAST_BATTERY_SMART_COST_LIMIT = "last_battery_smart_cost_limit"; function read(key: string) { return window.localStorage[key]; @@ -48,6 +49,16 @@ function saveBool(key: string) { }; } +function readNumber(key: string) { + return read(key) ? parseFloat(read(key)) : undefined; +} + +function saveNumber(key: string) { + return (value: number | undefined) => { + save(key)(value ? value.toString() : null); + }; +} + function readArray(key: string) { const value = read(key); return value ? value.split(",") : []; @@ -83,6 +94,8 @@ export interface LoadpointSettings { order?: number; visible?: boolean; info?: SessionInfoKey; + lastSmartCostLimit?: number; + lastSmartFeedInPriorityLimit?: number; } export interface Settings { @@ -103,6 +116,7 @@ export interface Settings { sessionsType: string; solarAdjusted: boolean; loadpoints: Record; + lastBatterySmartCostLimit: number | undefined; } const settings: Settings = reactive({ @@ -123,6 +137,7 @@ const settings: Settings = reactive({ sessionsType: read(SESSIONS_TYPE), solarAdjusted: readBool(SETTINGS_SOLAR_ADJUSTED), loadpoints: readJSON(LOADPOINTS), + lastBatterySmartCostLimit: readNumber(LAST_BATTERY_SMART_COST_LIMIT), }); watch(() => settings.locale, save(SETTINGS_LOCALE)); @@ -142,6 +157,7 @@ watch(() => settings.sessionsGroup, save(SESSIONS_GROUP)); watch(() => settings.sessionsType, save(SESSIONS_TYPE)); watch(() => settings.solarAdjusted, saveBool(SETTINGS_SOLAR_ADJUSTED)); watch(() => settings.loadpoints, saveJSON(LOADPOINTS), { deep: true }); +watch(() => settings.lastBatterySmartCostLimit, saveNumber(LAST_BATTERY_SMART_COST_LIMIT)); export default settings; diff --git a/assets/js/theme.ts b/assets/js/theme.ts index 6dec221ef..ab0dba8d3 100644 --- a/assets/js/theme.ts +++ b/assets/js/theme.ts @@ -46,6 +46,7 @@ function updateTheme() { if ($html) { $html.classList.add("no-transitions"); $html.classList.toggle("dark", theme === THEME.DARK); + $html.setAttribute("data-bs-theme", theme!); window.setTimeout(function () { $html.classList.remove("no-transitions"); }, 100); diff --git a/assets/js/types/evcc.ts b/assets/js/types/evcc.ts index 1988ae80a..38ceb8d56 100644 --- a/assets/js/types/evcc.ts +++ b/assets/js/types/evcc.ts @@ -261,6 +261,8 @@ export interface UiLoadpoint extends Loadpoint { icon: string; order: number | null; visible: boolean; + lastSmartCostLimit: number | undefined; + lastSmartFeedInPriorityLimit: number | undefined; } export enum THEME { diff --git a/assets/js/uiLoadpoints.ts b/assets/js/uiLoadpoints.ts index d0cf2db27..cdb4b87bc 100644 --- a/assets/js/uiLoadpoints.ts +++ b/assets/js/uiLoadpoints.ts @@ -26,6 +26,8 @@ export const convertToUiLoadpoints = ( order: getLoadpointOrder(id), visible: isLoadpointVisible(id), sessionInfo: getLoadpointSessionInfo(id), + lastSmartCostLimit: getLoadpointLastSmartCostLimit(id), + lastSmartFeedInPriorityLimit: getLoadpointLastSmartFeedInPriorityLimit(id), }; }); @@ -56,10 +58,26 @@ export const getLoadpointSessionInfo = (id: string): SessionInfoKey | undefined return get(id).info; }; +export const getLoadpointLastSmartCostLimit = (id: string): number | undefined => { + return get(id).lastSmartCostLimit; +}; + export const setLoadpointSessionInfo = (id: string, value: SessionInfoKey) => { get(id).info = value; }; +export const setLoadpointLastSmartCostLimit = (id: string, value: number) => { + get(id).lastSmartCostLimit = value; +}; + +export const getLoadpointLastSmartFeedInPriorityLimit = (id: string): number | undefined => { + return get(id).lastSmartFeedInPriorityLimit; +}; + +export const setLoadpointLastSmartFeedInPriorityLimit = (id: string, value: number) => { + get(id).lastSmartFeedInPriorityLimit = value; +}; + export const resetLoadpointsOrder = () => { Object.keys(settings.loadpoints).forEach((id) => { if (settings.loadpoints[id]) { diff --git a/i18n/de.json b/i18n/de.json index ae0585bf4..93573fc61 100644 --- a/i18n/de.json +++ b/i18n/de.json @@ -1165,6 +1165,7 @@ "cleanTitle": "Sauberes Netzladen", "co2Label": "CO₂-Emission", "co2Limit": "CO₂-Grenze", + "enable": "Grenze aktivieren", "loadpointDescription": "Aktiviert vorübergehendes Schnellladen im PV-Modus.", "modalTitle": "Smartes Netzladen", "none": "keine", diff --git a/i18n/en.json b/i18n/en.json index d88a29319..bf4bd619e 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -1166,6 +1166,7 @@ "cleanTitle": "Clean Grid Charging", "co2Label": "CO₂ emission", "co2Limit": "CO₂ limit", + "enable": "Enable limit", "loadpointDescription": "Enables temporary fast-charging in solar mode.", "modalTitle": "Smart Grid Charging", "none": "none", diff --git a/tests/battery-settings-co2.spec.ts b/tests/battery-settings-co2.spec.ts index 4e5d0b974..7758056cf 100644 --- a/tests/battery-settings-co2.spec.ts +++ b/tests/battery-settings-co2.spec.ts @@ -19,7 +19,9 @@ test.describe("battery settings co2", async () => { await expectModalVisible(modal); await modal.getByRole("link", { name: "Grid charging" }).click(); + await modal.getByLabel("Enable limit").check(); await modal.getByLabel("CO₂ limit").selectOption({ label: "≤ 150 g/kWh" }); + await expect(modal.getByTestId("active-hours")).toHaveText(["Active time", "48 hr"].join("")); await expect(modal).toContainText("20 g – 150 g"); await page.getByRole("button", { name: "Close" }).click(); await expectModalHidden(modal); @@ -27,6 +29,7 @@ test.describe("battery settings co2", async () => { await page.getByRole("button", { name: "grid charging active (≤ 150 g)" }).click(); await expectModalVisible(modal); await modal.getByLabel("CO₂ limit").selectOption({ label: "≤ 10 g/kWh" }); + await expect(modal.getByTestId("active-hours")).toHaveText("Active time"); await modal.getByRole("button", { name: "Close" }).click(); await expectModalHidden(modal); await expect(modal).not.toBeVisible(); diff --git a/tests/battery-settings.spec.ts b/tests/battery-settings.spec.ts index f25715417..4c4b83922 100644 --- a/tests/battery-settings.spec.ts +++ b/tests/battery-settings.spec.ts @@ -47,7 +47,9 @@ test.describe("battery settings", async () => { await expectModalVisible(modal); await modal.getByRole("link", { name: "Grid charging" }).click(); + await modal.getByLabel("Enable limit").check(); await modal.getByLabel("Price limit").selectOption({ label: "≤ 50.0 ct/kWh" }); + await expect(modal.getByTestId("active-hours")).toHaveText(["Active time", "48 hr"].join("")); await expect(modal).toContainText("5.0 ct – 50.0 ct"); await page.getByRole("button", { name: "Close" }).click(); await expectModalHidden(modal); @@ -55,6 +57,7 @@ test.describe("battery settings", async () => { await page.getByRole("button", { name: "grid charging active (≤ 50.0 ct)" }).click(); await expectModalVisible(modal); await modal.getByLabel("Price limit").selectOption({ label: "≤ -10.0 ct/kWh" }); + await expect(modal.getByTestId("active-hours")).toHaveText("Active time"); await modal.getByRole("button", { name: "Close" }).click(); await expectModalHidden(modal); await expect(modal).not.toBeVisible(); diff --git a/tests/smart-cost.spec.ts b/tests/smart-cost.spec.ts index 211de7b76..32e38f3ac 100644 --- a/tests/smart-cost.spec.ts +++ b/tests/smart-cost.spec.ts @@ -9,12 +9,14 @@ import { } from "./simulator"; test.use({ baseURL: baseUrl() }); +test.describe.configure({ mode: "parallel" }); -test.beforeAll(async () => { +test.beforeEach(async () => { await startSimulator(); await start(simulatorConfig()); }); -test.afterAll(async () => { + +test.afterEach(async () => { await stop(); await stopSimulator(); }); @@ -36,26 +38,26 @@ test.describe("smart cost limit", async () => { test("price below limit", async ({ page }) => { await page.goto("/"); await page.getByTestId("loadpoint-settings-button").nth(1).click(); - await expect(page.getByTestId("loadpoint-settings-modal")).toBeVisible(); - await page - .getByTestId("loadpoint-settings-modal") - .getByLabel("Price limit") - .selectOption("≤ 40.0 ct/kWh"); - await page.getByTestId("loadpoint-settings-modal").getByLabel("Close").click(); - await expect(page.getByTestId("loadpoint-settings-modal")).not.toBeVisible(); + const modal = page.getByTestId("loadpoint-settings-modal"); + await expect(modal).toBeVisible(); + 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", "48 hr"].join("")); + await modal.getByLabel("Close").click(); + await expect(modal).not.toBeVisible(); await expect(page.getByTestId("vehicle-status-charger")).toHaveText("Charging…"); await expect(page.getByTestId("vehicle-status-smartcost")).toHaveText(/[24]0\.0 ct ≤ 40\.0 ct/); }); test("price above limit", async ({ page }) => { await page.goto("/"); await page.getByTestId("loadpoint-settings-button").nth(1).click(); - await expect(page.getByTestId("loadpoint-settings-modal")).toBeVisible(); - await page - .getByTestId("loadpoint-settings-modal") - .getByLabel("Price limit") - .selectOption("≤ 10.0 ct/kWh"); - await page.getByTestId("loadpoint-settings-modal").getByLabel("Close").click(); - await expect(page.getByTestId("loadpoint-settings-modal")).not.toBeVisible(); + const modal = page.getByTestId("loadpoint-settings-modal"); + await expect(modal).toBeVisible(); + 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 expect(page.getByTestId("vehicle-status-charger")).toHaveText("Charging…"); await expect(page.getByTestId("vehicle-status-smartcost")).toHaveText("≤ 10.0 ct"); }); diff --git a/tests/smart-feedin.spec.ts b/tests/smart-feedin.spec.ts index 9916fdb15..6ec39d647 100644 --- a/tests/smart-feedin.spec.ts +++ b/tests/smart-feedin.spec.ts @@ -5,12 +5,13 @@ import { expectModalHidden, expectModalVisible } from "./utils"; const CONFIG = "smart-feedin.evcc.yaml"; test.use({ baseURL: baseUrl() }); +test.describe.configure({ mode: "parallel" }); -test.beforeAll(async () => { +test.beforeEach(async () => { await start(CONFIG); }); -test.afterAll(async () => { +test.afterEach(async () => { await stop(); }); @@ -28,7 +29,9 @@ test.describe("smart feed-in priority", async () => { await lp.getByTestId("loadpoint-settings-button").nth(1).click(); const modal = page.getByTestId("loadpoint-settings-modal").first(); await expectModalVisible(modal); + await modal.getByLabel("Enable limit").check(); await modal.getByLabel("Feed-in limit").selectOption("≥ 10.0 ct/kWh"); + await expect(modal.getByTestId("active-hours")).toHaveText(["Paused time", "48 hr"].join("")); await modal.getByLabel("Close").click(); await expectModalHidden(modal); @@ -39,7 +42,7 @@ test.describe("smart feed-in priority", async () => { // remove limit await lp.getByTestId("loadpoint-settings-button").nth(1).click(); await expectModalVisible(modal); - await modal.getByLabel("Feed-in limit").selectOption("none"); + await modal.getByLabel("Enable limit").uncheck(); await modal.getByLabel("Close").click(); await expectModalHidden(modal); @@ -54,7 +57,9 @@ test.describe("smart feed-in priority", async () => { await lp1.getByTestId("loadpoint-settings-button").nth(1).click(); const modal1 = page.getByTestId("loadpoint-settings-modal").first(); await expectModalVisible(modal1); + await modal1.getByLabel("Enable limit").check(); await modal1.getByLabel("Feed-in limit").selectOption("≥ 10.0 ct/kWh"); + await expect(modal1.getByTestId("active-hours")).toHaveText(["Paused time", "48 hr"].join("")); await modal1.getByRole("button", { name: "Apply everywhere?" }).click(); await modal1.getByLabel("Close").click(); await expectModalHidden(modal1);