Grid Charge UI: toggle limit (#25131)

This commit is contained in:
Michael Geers 2025-11-12 14:18:05 +01:00 • committed by GitHub
parent a4badb9e61
commit ab46d8150a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
16 changed files with 184 additions and 64 deletions

View file

@ -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) {

View file

@ -24,6 +24,7 @@
</div>
</div>
<LoadpointSettingsModal
:id="id"
v-bind="settingsModal"
@maxcurrent-updated="setMaxCurrent"
@mincurrent-updated="setMinCurrent"
@ -133,7 +134,7 @@ export default defineComponent({
},
mixins: [formatter, collector],
props: {
id: String,
id: { type: String, required: true },
single: Boolean,
// main
@ -224,6 +225,8 @@ export default defineComponent({
gridConfigured: Boolean,
pvConfigured: Boolean,
forecast: Object as PropType<Forecast>,
lastSmartCostLimit: Number,
lastSmartFeedInPriorityLimit: Number,
},
data() {
return {

View file

@ -10,10 +10,11 @@
<div class="container">
<SmartCostLimit
:current-limit="smartCostLimit"
:last-limit="lastSmartCostLimit"
:smart-cost-type="smartCostType"
:currency="currency"
is-loadpoint
:loadpoint-id="Number(loadpointId)"
:loadpoint-id="loadpointId"
:multiple-loadpoints="multipleLoadpoints"
:possible="smartCostAvailable"
:tariff="forecast?.planner"
@ -21,8 +22,9 @@
/>
<SmartFeedInPriority
:current-limit="smartFeedInPriorityLimit"
:last-limit="lastSmartFeedInPriorityLimit"
:currency="currency"
:loadpoint-id="Number(loadpointId)"
:loadpoint-id="loadpointId"
:multiple-loadpoints="multipleLoadpoints"
:possible="smartFeedInPriorityAvailable"
:tariff="forecast?.feedin"
@ -161,7 +163,7 @@ export default defineComponent({
},
mixins: [formatter, collector],
props: {
id: [String, Number],
id: { type: String, required: true },
phasesConfigured: { type: Number, default: 0 },
chargerPhases1p3p: Boolean,
chargerSinglePhase: Boolean,
@ -181,6 +183,8 @@ export default defineComponent({
currency: String as PropType<CURRENCY>,
multipleLoadpoints: Boolean,
forecast: Object as PropType<Forecast>,
lastSmartCostLimit: Number,
lastSmartFeedInPriorityLimit: Number,
},
emits: [
"phasesconfigured-updated",

View file

@ -2,6 +2,7 @@
<SmartTariffBase
v-bind="labels"
:current-limit="currentLimit"
:last-limit="lastLimit"
:is-co2="isCo2"
:currency="currency"
:apply-all="multipleLoadpoints && isLoadpoint"
@ -21,6 +22,8 @@
import SmartTariffBase from "./SmartTariffBase.vue";
import { defineComponent, type PropType } from "vue";
import api from "@/api";
import { setLoadpointLastSmartCostLimit } from "@/uiLoadpoints";
import settings from "@/settings";
import { type CURRENCY, SMART_COST_TYPE } from "@/types/evcc";
import { type ForecastSlot } from "../Forecast/types";
@ -36,8 +39,9 @@ export default defineComponent({
currency: String as PropType<CURRENCY>,
multipleLoadpoints: Boolean,
isLoadpoint: Boolean,
loadpointId: Number,
loadpointId: String,
possible: Boolean,
lastLimit: Number,
tariff: Array as PropType<ForecastSlot[]>,
},
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";

View file

@ -2,6 +2,7 @@
<SmartTariffBase
v-bind="labels"
:current-limit="currentLimit"
:last-limit="lastLimit"
:currency="currency"
:apply-all="multipleLoadpoints"
:possible="possible"
@ -23,6 +24,7 @@ import { defineComponent, type PropType } from "vue";
import SmartTariffBase from "./SmartTariffBase.vue";
import api from "@/api";
import { type CURRENCY } from "@/types/evcc";
import { setLoadpointLastSmartFeedInPriorityLimit } from "@/uiLoadpoints";
export default defineComponent({
name: "SmartFeedInPriority",
@ -32,8 +34,9 @@ export default defineComponent({
type: [Number, null] as PropType<number | null>,
required: true,
},
lastLimit: Number,
currency: String as PropType<CURRENCY>,
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);
},

View file

@ -4,22 +4,38 @@
<p>
{{ description }}
</p>
<div class="row mb-3">
<div class="row mb-3 align-items-center">
<label :for="formId" class="col-sm-4 col-form-label pt-0 pt-sm-2">
{{ limitLabel }}
</label>
<div class="col-sm-8 col-lg-4 pe-0">
<select
:id="formId"
v-model.number="selectedLimit"
class="form-select form-select-sm mb-1"
@change="changeLimit"
>
<option value="null">{{ $t("smartCost.none") }}</option>
<option v-for="{ value, name } in limitOptions" :key="value" :value="value">
{{ name }}
</option>
</select>
<div class="input-group input-group-sm mb-1 mb-lg-0">
<div class="input-group-text">
<div class="form-check form-switch m-0">
<input
:id="formId + 'Active'"
:checked="active"
class="form-check-input"
type="checkbox"
role="switch"
:aria-label="$t('smartCost.enable')"
@change="toggleActive"
/>
</div>
</div>
<select
:id="formId"
v-model.number="selectedLimit"
class="form-select form-select-sm"
:disabled="!active"
:aria-label="limitLabel"
@change="changeLimit"
>
<option v-for="{ value, name } in limitOptions" :key="value" :value="value">
{{ name }}
</option>
</select>
</div>
</div>
<div
v-if="applyToAllVisible"
@ -32,7 +48,7 @@
</div>
</div>
<div class="justify-content-between mb-2 d-flex justify-content-between">
<div class="text-start">
<div class="text-start" data-testid="active-hours">
<div class="label">
{{ activeHoursLabel }}
</div>
@ -91,6 +107,7 @@ export default defineComponent({
type: [Number, null] as PropType<number | null>,
required: true,
},
lastLimit: { type: Number, default: 0 },
isCo2: Boolean,
currency: String as PropType<CURRENCY>,
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;
},
},

View file

@ -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<string, LoadpointSettings>;
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;

View file

@ -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);

View file

@ -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 {

View file

@ -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]) {

View file

@ -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",

View file

@ -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",

View file

@ -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();

View file

@ -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();

View file

@ -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");
});

View file

@ -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);