refactor: use single modal instances for loadpoint (#29109)

Co-authored-by: Michael Geers <michael@geers.tv>
This commit is contained in:
Maschga 2026-04-29 14:45:48 +02:00 • committed by GitHub
parent d78491a07b
commit 3f19b6a6de
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 451 additions and 343 deletions

View file

@ -28,93 +28,26 @@
</button>
</div>
</LabelAndValue>
<Teleport to="body">
<GenericModal
:id="modalId"
ref="modal"
:title="modalTitle"
size="xl"
data-testid="charging-plan-modal"
@open="modalVisible"
@closed="modalInvisible"
>
<div class="pt-2">
<ul class="nav nav-tabs">
<li class="nav-item">
<a
class="nav-link"
:class="{ active: departureTabActive }"
href="#"
@click.prevent="showDeatureTab"
>
{{ $t("main.chargingPlan.departureTab") }}
</a>
</li>
<li class="nav-item">
<a
class="nav-link"
:class="{ active: arrivalTabActive }"
href="#"
@click.prevent="showArrivalTab"
>
{{ $t("main.chargingPlan.arrivalTab") }}
</a>
</li>
</ul>
<div v-if="isModalVisible">
<ChargingPlansSettings
v-if="departureTabActive"
v-bind="chargingPlansSettingsProps"
@static-plan-updated="updateStaticPlan"
@static-plan-removed="removeStaticPlan"
@repeating-plans-updated="updateRepeatingPlans"
@plan-strategy-updated="updatePlanStrategy"
/>
<ChargingPlanArrival
v-if="arrivalTabActive"
v-bind="chargingPlanArrival"
@minsoc-updated="setMinSoc"
@limitsoc-updated="setLimitSoc"
/>
</div>
</div>
</GenericModal>
</Teleport>
</div>
</template>
<script lang="ts">
import GenericModal from "../Helper/GenericModal.vue";
import LabelAndValue from "../Helper/LabelAndValue.vue";
import PlansSettings from "./PlansSettings.vue";
import Arrival from "./Arrival.vue";
import formatter from "@/mixins/formatter";
import collector from "@/mixins/collector";
import minuteTicker from "@/mixins/minuteTicker";
import api from "@/api";
import { optionStep, fmtEnergy } from "@/utils/energyOptions.ts";
import { defineComponent, type PropType } from "vue";
import type { CURRENCY, Vehicle } from "@/types/evcc";
import type {
StaticPlan,
StaticSocPlan,
StaticEnergyPlan,
RepeatingPlan,
PlanStrategy,
} from "./types";
import type { PlanStrategy } from "./types";
import type { Forecast } from "@/types/evcc.ts";
export default defineComponent({
name: "ChargingPlan",
components: {
GenericModal,
LabelAndValue,
ChargingPlansSettings: PlansSettings,
ChargingPlanArrival: Arrival,
},
mixins: [formatter, collector, minuteTicker],
mixins: [formatter, minuteTicker],
props: {
currency: String as PropType<CURRENCY>,
disabled: Boolean,
@ -142,10 +75,9 @@ export default defineComponent({
vehicleNotReachable: Boolean,
forecast: Object as PropType<Forecast>,
},
emits: ["open-modal"],
data() {
return {
isModalVisible: false,
activeTab: "departure",
targetTimeLabel: "",
};
},
@ -165,60 +97,9 @@ export default defineComponent({
limitSoc(): number | undefined {
return this.vehicle?.limitSoc;
},
staticPlan(): StaticPlan | null {
if (this.socBasedPlanning) {
const plan = this.vehicle?.plan as StaticSocPlan;
if (plan) {
return {
soc: plan.soc,
time: new Date(plan.time),
};
}
return null;
}
if (this.planEnergy && this.planTime) {
return {
energy: this.planEnergy,
time: new Date(this.planTime),
};
}
return null;
},
repeatingPlans(): RepeatingPlan[] {
if (
this.vehicle &&
this.vehicle.repeatingPlans &&
this.vehicle.repeatingPlans.length > 0
) {
return [...(this.vehicle.repeatingPlans || [])];
}
return [];
},
enabled(): boolean {
return !!this.effectivePlanTime;
},
modalId(): string {
return `chargingPlanModal_${this.id}`;
},
modalTitle(): string {
const baseTitle = this.$t("main.chargingPlan.modalTitle");
if (this.socBasedPlanning && this.vehicle) {
return `${baseTitle}: ${this.vehicle.title}`;
}
return baseTitle;
},
departureTabActive(): boolean {
return this.activeTab === "departure";
},
arrivalTabActive(): boolean {
return this.activeTab === "arrival";
},
chargingPlansSettingsProps(): any {
return this.collectProps(PlansSettings);
},
chargingPlanArrival(): any {
return this.collectProps(Arrival);
},
targetSocLabel(): string {
if (this.socBasedPlanning && this.effectivePlanSoc) {
return this.fmtPercentage(this.effectivePlanSoc);
@ -230,12 +111,6 @@ export default defineComponent({
this.$t("main.targetEnergy.noLimit")
);
},
apiVehicle(): string {
return `vehicles/${this.vehicle?.name}/`;
},
apiLoadpoint(): string {
return `loadpoints/${this.id}/`;
},
},
watch: {
effectivePlanTime(): void {
@ -254,70 +129,14 @@ export default defineComponent({
this.updateTargetTimeLabel();
},
methods: {
modalVisible(): void {
this.isModalVisible = true;
},
modalInvisible(): void {
this.isModalVisible = false;
},
openModal(): void {
this.showDeatureTab();
const modalRef = this.$refs["modal"] as any;
modalRef?.open();
},
openPlanModal(arrivalTab = false) {
if (arrivalTab) {
this.showArrivalTab();
} else {
this.showDeatureTab();
}
const modalRef = this.$refs["modal"] as any;
modalRef?.open();
this.$emit("open-modal");
},
updateTargetTimeLabel(): void {
if (!this.effectivePlanTime) return;
const targetDate = new Date(this.effectivePlanTime);
this.targetTimeLabel = this.fmtAbsoluteDate(targetDate);
},
showDeatureTab(): void {
this.activeTab = "departure";
},
showArrivalTab(): void {
this.activeTab = "arrival";
},
updateStaticPlan(plan: StaticPlan): void {
const timeISO = plan.time.toISOString();
if (this.socBasedPlanning) {
const p = plan as StaticSocPlan;
api.post(`${this.apiVehicle}plan/soc/${p.soc}/${timeISO}`, null);
} else {
const p = plan as StaticEnergyPlan;
api.post(`${this.apiLoadpoint}plan/energy/${p.energy}/${timeISO}`, null);
}
},
removeStaticPlan(): void {
if (this.socBasedPlanning) {
api.delete(`${this.apiVehicle}plan/soc`);
} else {
api.delete(`${this.apiLoadpoint}plan/energy`);
}
},
updateRepeatingPlans(plans: RepeatingPlan[]): void {
api.post(`${this.apiVehicle}plan/repeating`, plans);
},
updatePlanStrategy(strategy: PlanStrategy): void {
if (this.socBasedPlanning) {
api.post(`${this.apiVehicle}plan/strategy`, strategy);
} else {
api.post(`${this.apiLoadpoint}plan/strategy`, strategy);
}
},
setMinSoc(soc: number): void {
api.post(`${this.apiVehicle}minsoc/${soc}`);
},
setLimitSoc(soc: number): void {
api.post(`${this.apiVehicle}limitsoc/${soc}`);
},
},
});
</script>

View file

@ -0,0 +1,226 @@
<template>
<Teleport to="body">
<GenericModal
id="chargingPlanModal"
ref="modal"
:title="modalTitle"
size="xl"
data-testid="charging-plan-modal"
@open="modalVisible"
@closed="modalInvisible"
>
<div class="pt-2">
<ul class="nav nav-tabs">
<li class="nav-item">
<a
class="nav-link"
:class="{ active: departureTabActive }"
href="#"
@click.prevent="showDepartureTab"
>
{{ $t("main.chargingPlan.departureTab") }}
</a>
</li>
<li class="nav-item">
<a
class="nav-link"
:class="{ active: arrivalTabActive }"
href="#"
@click.prevent="showArrivalTab"
>
{{ $t("main.chargingPlan.arrivalTab") }}
</a>
</li>
</ul>
<div v-if="isModalVisible">
<PlansSettings
v-if="departureTabActive"
:id="id"
:staticPlan="staticPlan"
:repeatingPlans="repeatingPlans"
:effectiveLimitSoc="loadpoint?.effectiveLimitSoc"
:effectivePlanTime="loadpoint?.effectivePlanTime ?? undefined"
:effectivePlanSoc="loadpoint?.effectivePlanSoc"
:effectivePlanStrategy="loadpoint?.effectivePlanStrategy"
:planEnergy="loadpoint?.planEnergy"
:limitEnergy="loadpoint?.limitEnergy"
:socBasedPlanning="!!loadpoint?.socBasedPlanning"
:socPerKwh="loadpoint?.socPerKwh"
:rangePerSoc="loadpoint?.rangePerSoc"
:smartCostType="smartCostType"
:currency="currency"
:mode="loadpoint?.mode"
:capacity="vehicle?.capacity"
:vehicle="vehicle"
:vehicleLimitSoc="loadpoint?.vehicleLimitSoc"
:planOverrun="loadpoint?.planOverrun"
:forecast="forecast"
@static-plan-updated="updateStaticPlan"
@static-plan-removed="removeStaticPlan"
@repeating-plans-updated="updateRepeatingPlans"
@plan-strategy-updated="updatePlanStrategy"
/>
<Arrival
v-if="arrivalTabActive"
:id="id"
:minSoc="vehicle?.minSoc"
:limitSoc="vehicle?.limitSoc"
:vehicleName="vehicle?.name"
:vehicleNotReachable="loadpoint?.vehicleNotReachable"
:socBasedCharging="loadpoint?.socBasedCharging"
:rangePerSoc="loadpoint?.rangePerSoc"
@minsoc-updated="setMinSoc"
@limitsoc-updated="setLimitSoc"
/>
</div>
</div>
</GenericModal>
</Teleport>
</template>
<script lang="ts">
import { defineComponent, type PropType } from "vue";
import GenericModal from "../Helper/GenericModal.vue";
import PlansSettings from "./PlansSettings.vue";
import Arrival from "./Arrival.vue";
import api from "@/api";
import type {
PlanStrategy,
RepeatingPlan,
StaticEnergyPlan,
StaticPlan,
StaticSocPlan,
} from "./types";
import type { CURRENCY, Forecast, SMART_COST_TYPE, UiLoadpoint, Vehicle } from "@/types/evcc";
export default defineComponent({
name: "ChargingPlanModal",
components: {
GenericModal,
PlansSettings,
Arrival,
},
props: {
loadpoints: { type: Array as PropType<UiLoadpoint[]>, default: () => [] },
vehicles: { type: Array as PropType<Vehicle[]>, default: () => [] },
smartCostType: String as PropType<SMART_COST_TYPE>,
currency: String as PropType<CURRENCY>,
forecast: Object as PropType<Forecast>,
},
data() {
return {
isModalVisible: false,
activeTab: "departure",
id: undefined as string | number | undefined,
};
},
computed: {
staticPlan(): StaticPlan | undefined {
if (this.loadpoint?.socBasedPlanning) {
const plan = this.vehicle?.plan as StaticSocPlan;
if (plan) {
return {
soc: plan.soc,
time: new Date(plan.time),
};
}
return undefined;
}
if (this.loadpoint?.planEnergy && this.loadpoint?.planTime) {
return {
energy: this.loadpoint.planEnergy,
time: new Date(this.loadpoint.planTime),
};
}
return undefined;
},
loadpoint() {
return this.loadpoints.find((loadpoint) => loadpoint.id === this.id);
},
vehicle() {
return this.vehicles?.find((v) => v.name === this.loadpoint?.vehicleName);
},
modalTitle(): string {
const baseTitle = this.$t("main.chargingPlan.modalTitle");
if (this.loadpoint?.socBasedPlanning && this.vehicle) {
return `${baseTitle}: ${this.vehicle.title}`;
}
return baseTitle;
},
departureTabActive(): boolean {
return this.activeTab === "departure";
},
arrivalTabActive(): boolean {
return this.activeTab === "arrival";
},
apiVehicle(): string {
return `vehicles/${this.vehicle?.name}/`;
},
apiLoadpoint(): string {
return `loadpoints/${this.id}/`;
},
repeatingPlans(): RepeatingPlan[] {
if (
this.vehicle &&
this.vehicle.repeatingPlans &&
this.vehicle.repeatingPlans.length > 0
) {
return this.vehicle.repeatingPlans || [];
}
return [];
},
},
methods: {
open(loadpointId?: string | number) {
this.id = loadpointId;
const modalRef = this.$refs["modal"] as InstanceType<typeof GenericModal> | undefined;
modalRef?.open();
},
modalVisible(): void {
this.isModalVisible = true;
},
modalInvisible(): void {
this.isModalVisible = false;
},
setMinSoc(soc: number): void {
api.post(`${this.apiVehicle}minsoc/${soc}`);
},
setLimitSoc(soc: number): void {
api.post(`${this.apiVehicle}limitsoc/${soc}`);
},
updateStaticPlan(plan: StaticPlan): void {
const timeISO = plan.time.toISOString();
if (this.loadpoint?.socBasedPlanning) {
const p = plan as StaticSocPlan;
api.post(`${this.apiVehicle}plan/soc/${p.soc}/${timeISO}`, null);
} else {
const p = plan as StaticEnergyPlan;
api.post(`${this.apiLoadpoint}plan/energy/${p.energy}/${timeISO}`, null);
}
},
removeStaticPlan(): void {
if (this.loadpoint?.socBasedPlanning) {
api.delete(`${this.apiVehicle}plan/soc`);
} else {
api.delete(`${this.apiLoadpoint}plan/energy`);
}
},
updateRepeatingPlans(plans: RepeatingPlan[]): void {
api.post(`${this.apiVehicle}plan/repeating`, plans);
},
updatePlanStrategy(strategy: PlanStrategy): void {
if (this.loadpoint?.socBasedPlanning) {
api.post(`${this.apiVehicle}plan/strategy`, strategy);
} else {
api.post(`${this.apiLoadpoint}plan/strategy`, strategy);
}
},
showDepartureTab(): void {
this.activeTab = "departure";
},
showArrivalTab(): void {
this.activeTab = "arrival";
},
},
});
</script>

View file

@ -31,14 +31,6 @@
/>
</div>
</div>
<LoadpointSettingsModal
:id="id"
v-bind="settingsModal"
@maxcurrent-updated="setMaxCurrent"
@mincurrent-updated="setMinCurrent"
@phasesconfigured-updated="setPhasesConfigured"
@batteryboostlimit-updated="setBatteryBoostLimit"
/>
<div
v-if="remoteDisabled"
@ -91,12 +83,14 @@
<Vehicle
class="flex-grow-1 d-flex flex-column justify-content-end"
v-bind="vehicleProps"
:soc-per-kwh="socPerKwh"
@limit-soc-updated="setLimitSoc"
@limit-energy-updated="setLimitEnergy"
@change-vehicle="changeVehicle"
@remove-vehicle="removeVehicle"
@open-loadpoint-settings="openSettingsModal"
@batteryboost-updated="setBatteryBoost"
@open-modal="(openArrivalTab) => $emit('open-charging-plan-modal', openArrivalTab)"
/>
</div>
</template>
@ -115,11 +109,9 @@ import SettingsButton from "./SettingsButton.vue";
import SettingsModal from "./SettingsModal.vue";
import VehicleIcon from "../VehicleIcon";
import SessionInfo from "./SessionInfo.vue";
import Modal from "bootstrap/js/dist/modal";
import { defineComponent, type PropType } from "vue";
import type {
CHARGE_MODE,
PHASES,
PHASE_ACTION,
PV_ACTION,
CHARGER_STATUS_REASON,
@ -139,7 +131,6 @@ export default defineComponent({
Phases,
LabelAndValue,
LoadpointSettingsButton: SettingsButton,
LoadpointSettingsModal: SettingsModal,
LoadpointSessionInfo: SessionInfo,
VehicleIcon,
},
@ -243,7 +234,17 @@ export default defineComponent({
forecast: Object as PropType<Forecast>,
lastSmartCostLimit: Number,
lastSmartFeedInPriorityLimit: Number,
vehicleKnown: Boolean,
vehicleHasSoc: Boolean,
vehicleNotReachable: Boolean,
socBasedCharging: Boolean,
socBasedPlanning: Boolean,
capacity: Number,
range: Number,
rangePerSoc: Number,
socPerKwh: { type: Number, required: true },
},
emits: ["open-charging-plan-modal", "open-settings-modal"],
data() {
return {
tickerHandler: null as Timeout,
@ -290,27 +291,10 @@ export default defineComponent({
showChargingIndicator() {
return this.charging && this.chargePower > 0;
},
vehicleKnown() {
return !!this.vehicleName;
},
vehicleHasSoc() {
return this.vehicleKnown && !this.vehicle?.features?.includes("Offline");
},
vehicleNotReachable() {
// online vehicle that was not reachable at startup
const features = this.vehicle?.features || [];
return features.includes("Offline") && features.includes("Retryable");
},
planTimeUnreachable() {
// 1 minute tolerance
return this.planOverrun > 60;
},
socBasedCharging() {
return this.vehicleHasSoc || this.vehicleSoc > 0;
},
socBasedPlanning() {
return this.socBasedCharging && this.vehicle?.capacity && this.vehicle?.capacity > 0;
},
pvPossible() {
return this.pvConfigured || this.gridConfigured;
},
@ -379,15 +363,6 @@ export default defineComponent({
setLimitEnergy(kWh: number) {
api.post(this.apiPath("limitenergy") + "/" + kWh);
},
setMaxCurrent(maxCurrent: number) {
api.post(this.apiPath("maxcurrent") + "/" + maxCurrent);
},
setMinCurrent(minCurrent: number) {
api.post(this.apiPath("mincurrent") + "/" + minCurrent);
},
setPhasesConfigured(phases: PHASES) {
api.post(this.apiPath("phases") + "/" + phases);
},
changeVehicle(name: string) {
api.post(this.apiPath("vehicle") + `/${name}`);
},
@ -397,9 +372,6 @@ export default defineComponent({
setBatteryBoost(batteryBoost: boolean) {
api.post(this.apiPath("batteryboost") + `/${batteryBoost ? "1" : "0"}`);
},
setBatteryBoostLimit(limit: number) {
api.post(this.apiPath("batteryboostlimit") + "/" + limit);
},
fmtPower(value: number) {
return this.fmtW(value, POWER_UNIT.AUTO);
},
@ -407,10 +379,7 @@ export default defineComponent({
return this.fmtWh(value, POWER_UNIT.AUTO);
},
openSettingsModal() {
const modal = Modal.getOrCreateInstance(
document.getElementById(`loadpointSettingsModal_${this.id}`) as HTMLElement
);
modal.show();
this.$emit("open-settings-modal");
},
},
});

View file

@ -1,6 +1,6 @@
import Loadpoints from "./Loadpoints.vue";
import type { Meta, StoryFn } from "@storybook/vue3";
import { SMART_COST_TYPE } from "@/types/evcc";
import { CURRENCY, SMART_COST_TYPE } from "@/types/evcc";
// Create LoadpointCompact structure for the Loadpoints component
const createLoadpoint = (opts: any = {}) => {
@ -84,7 +84,7 @@ const baseArgs = {
tariffGrid: 0.144,
tariffCo2: 252,
tariffFeedIn: 0.08,
currency: "EUR",
currency: CURRENCY.EUR,
gridConfigured: true,
pvConfigured: true,
batteryConfigured: true,

View file

@ -36,6 +36,10 @@
class="h-100"
:class="{ 'loadpoint-unselected': !selected(loadpoint.id) }"
@click="goTo(loadpoint.id)"
@open-charging-plan-modal="
(openArrivalTab) => openChargingPlanModal(loadpoint.id, openArrivalTab)
"
@open-settings-modal="openSettingsModal(loadpoint.id)"
/>
</div>
</div>
@ -58,6 +62,28 @@
<shopicon-bold-circle v-else class="indicator-icon"></shopicon-bold-circle>
</button>
</div>
<div>
<ChargingPlanModal
ref="chargingPlanModal"
:loadpoints="loadpoints"
:vehicles="vehicles"
:smartCostType="smartCostType"
:currency="currency"
:forecast="forecast"
/>
<SettingsModal
ref="settingsModal"
:loadpoints="loadpoints"
:multipleLoadpoints="multipleLoadpoints"
:currency="currency"
:tariffGrid="tariffGrid"
:smartFeedInPriorityAvailable="smartFeedInPriorityAvailable"
:smartCostAvailable="smartCostAvailable"
:smartCostType="smartCostType"
:battery-configured="batteryConfigured"
:forecast="forecast"
/>
</div>
</div>
</template>
@ -68,11 +94,21 @@ import "@h2d2/shopicons/es/filled/lightning";
import Loadpoint from "./Loadpoint.vue";
import { defineComponent, type PropType } from "vue";
import type { UiLoadpoint, SMART_COST_TYPE, Timeout, Vehicle, BATTERY_MODE } from "@/types/evcc";
import type {
UiLoadpoint,
SMART_COST_TYPE,
Timeout,
Vehicle,
BATTERY_MODE,
Forecast,
CURRENCY,
} from "@/types/evcc";
import ChargingPlanModal from "../ChargingPlans/ChargingPlanModal.vue";
import SettingsModal from "../Loadpoints/SettingsModal.vue";
export default defineComponent({
name: "Loadpoints",
components: { Loadpoint },
components: { Loadpoint, ChargingPlanModal, SettingsModal },
props: {
loadpoints: { type: Array as PropType<UiLoadpoint[]>, default: () => [] },
vehicles: { type: Array as PropType<Vehicle[]> },
@ -82,14 +118,14 @@ export default defineComponent({
tariffGrid: Number,
tariffCo2: Number,
tariffFeedIn: Number,
currency: String,
currency: String as PropType<CURRENCY>,
selectedId: String,
gridConfigured: Boolean,
pvConfigured: Boolean,
batteryConfigured: Boolean,
batterySoc: Number,
batteryMode: String as PropType<BATTERY_MODE>,
forecast: Object, // as PropType<Forecast>,
forecast: Object as PropType<Forecast>,
},
emits: ["id-changed"],
data() {
@ -188,6 +224,25 @@ export default defineComponent({
}
}, 1000);
},
openChargingPlanModal(loadpointId: string, openArrivalTab = false) {
const modal = this.$refs["chargingPlanModal"] as
| InstanceType<typeof ChargingPlanModal>
| undefined;
if (openArrivalTab) {
modal?.showArrivalTab();
} else {
modal?.showDepartureTab();
}
modal?.open(loadpointId);
},
openSettingsModal(loadpointId: string) {
const modal = this.$refs["settingsModal"] as
| InstanceType<typeof SettingsModal>
| undefined;
modal?.open(loadpointId);
},
},
});
</script>

View file

@ -1,30 +1,31 @@
<template>
<GenericModal
:id="`loadpointSettingsModal_${id}`"
:title="$t('main.loadpointSettings.title', [title])"
ref="modal"
:title="$t('main.loadpointSettings.title', [loadpoint?.title])"
size="xl"
data-testid="loadpoint-settings-modal"
@opened="modalVisible"
@open="modalVisible"
@closed="modalInvisible"
>
<div class="container">
<SmartCostLimit
:current-limit="smartCostLimit"
:last-limit="lastSmartCostLimit"
:current-limit="loadpoint?.smartCostLimit || null"
:last-limit="loadpoint?.lastSmartCostLimit"
:smart-cost-type="smartCostType"
:currency="currency"
is-loadpoint
:loadpoint-id="loadpointId"
:loadpoint-id="id"
:multiple-loadpoints="multipleLoadpoints"
:possible="smartCostAvailable"
:tariff="forecast?.planner"
class="mt-2 mb-4"
/>
<SmartFeedInPriority
:current-limit="smartFeedInPriorityLimit"
:last-limit="lastSmartFeedInPriorityLimit"
:current-limit="loadpoint?.smartFeedInPriorityLimit || null"
:last-limit="loadpoint?.lastSmartFeedInPriorityLimit"
:currency="currency"
:loadpoint-id="loadpointId"
:loadpoint-id="id"
:multiple-loadpoints="multipleLoadpoints"
:possible="smartFeedInPriorityAvailable"
:tariff="forecast?.feedin"
@ -34,7 +35,7 @@
v-if="batteryBoostAvailable"
v-bind="batteryBoostProps"
class="mt-2"
@batteryboostlimit-updated="changeBatteryBoostLimit"
@batteryboostlimit-updated="setBatteryBoostLimit"
/>
<h6>
{{ $t("main.loadpointSettings.currents") }}
@ -47,7 +48,7 @@
{{ $t("main.loadpointSettings.phasesConfigured.label") }}
</label>
<div class="col-sm-8 pe-0">
<p v-if="!chargerPhases1p3p" class="mt-0 mb-2">
<p v-if="!loadpoint?.chargerPhases1p3p" class="mt-0 mb-2">
<small>
{{ $t("main.loadpointSettings.phasesConfigured.no1p3pSupport") }}</small
>
@ -60,7 +61,7 @@
type="radio"
:name="formId('phases')"
:value="phases"
@change="changePhasesConfigured"
@change="setPhasesConfigured"
/>
<label class="form-check-label" :for="formId(`phases_${phases}`)">
{{ $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"
>
<option
v-for="{ value, name } in maxCurrentOptions"
@ -111,7 +112,7 @@
:id="formId('mincurrent')"
v-model.number="selectedMinCurrent"
class="form-select form-select-sm"
@change="changeMinCurrent"
@change="setMinCurrent"
>
<option
v-for="{ value, name } in minCurrentOptions"
@ -135,7 +136,8 @@ import SmartCostLimit from "../Tariff/SmartCostLimit.vue";
import SmartFeedInPriority from "../Tariff/SmartFeedInPriority.vue";
import SettingsBatteryBoost from "./SettingsBatteryBoost.vue";
import { defineComponent, type PropType } from "vue";
import { PHASES, CURRENCY, SMART_COST_TYPE, type Forecast } from "@/types/evcc";
import { PHASES, CURRENCY, SMART_COST_TYPE, type Forecast, type UiLoadpoint } from "@/types/evcc";
import api from "@/api";
const V = 230;
@ -163,49 +165,46 @@ export default defineComponent({
},
mixins: [formatter, collector],
props: {
id: { type: String, required: true },
phasesConfigured: { type: Number, default: 0 },
chargerPhases1p3p: Boolean,
chargerSinglePhase: Boolean,
batteryBoostLimit: { type: Number, default: 100 },
batteryBoostAvailable: Boolean,
mode: String,
minSoc: Number,
maxCurrent: { type: Number, default: 0 },
minCurrent: { type: Number, default: 0 },
title: String,
smartCostLimit: { type: Number as PropType<number | null>, default: null },
loadpoints: { type: Array as PropType<UiLoadpoint[]>, default: () => [] },
batteryConfigured: Boolean,
smartCostType: String as PropType<SMART_COST_TYPE>,
smartCostAvailable: Boolean,
smartFeedInPriorityLimit: { type: Number as PropType<number | null>, default: null },
smartFeedInPriorityAvailable: Boolean,
tariffGrid: Number,
currency: String as PropType<CURRENCY>,
multipleLoadpoints: Boolean,
forecast: Object as PropType<Forecast>,
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<typeof GenericModal> | 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);
},
},
});
</script>

View file

@ -36,7 +36,7 @@ export default defineComponent({
},
lastLimit: Number,
currency: String as PropType<CURRENCY>,
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

View file

@ -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"
/>
<div class="mt-2 mb-4 d-flex gap-2">
@ -54,6 +54,7 @@
class="flex-grow-1 target-charge"
v-bind="chargingPlan"
:disabled="chargingPlanDisabled"
@open-modal="$emit('open-modal')"
/>
<LimitSocSelect
v-if="socBasedCharging"
@ -86,7 +87,7 @@ import Status from "./Status.vue";
import ChargingPlan from "../ChargingPlans/ChargingPlan.vue";
import LimitSocSelect from "./LimitSocSelect.vue";
import LimitEnergySelect from "./LimitEnergySelect.vue";
import { distanceUnit, distanceValue } from "@/units.ts";
import { distanceUnit } from "@/units.ts";
import { defineComponent, type PropType } from "vue";
import {
CHARGE_MODE,
@ -97,6 +98,7 @@ import {
} from "@/types/evcc";
import type { PlanStrategy } from "@/components/ChargingPlans/types";
import BatteryBoostButton from "../Loadpoints/BatteryBoostButton.vue";
import type ChargingPlanModal from "../ChargingPlans/ChargingPlanModal.vue";
export default defineComponent({
name: "Vehicle",
@ -169,6 +171,10 @@ export default defineComponent({
vehicleLimitSoc: Number,
vehicleNotReachable: Boolean,
minSocNotReached: Boolean,
capacity: Number,
range: Number,
rangePerSoc: Number,
socPerKwh: { type: Number, required: true },
},
emits: [
"limit-soc-updated",
@ -177,20 +183,21 @@ export default defineComponent({
"remove-vehicle",
"open-loadpoint-settings",
"batteryboost-updated",
"open-modal",
],
data() {
return {
displayLimitSoc: this.effectiveLimitSoc,
statusOverride: undefined as VehicleStatus | undefined,
chargingPlanModal: this.$refs["chargingPlanModal"] as
| InstanceType<typeof ChargingPlanModal>
| 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<typeof ChargingPlan> | undefined
)?.openPlanModal();
openPlanModal(openArrivalTab = false) {
this.$emit("open-modal", openArrivalTab);
},
handleBoostStatus(status: VehicleStatus) {
this.statusOverride = status;
},
openMinSocSettings() {
(
this.$refs["chargingPlan"] as InstanceType<typeof ChargingPlan> | undefined
)?.openPlanModal(true);
},
},
});
</script>

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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