Add schedule for planner (#16091)

This commit is contained in:
Maschga 2024-12-01 12:53:11 +01:00 • committed by GitHub
parent 35c9452d76
commit a35a705e48
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
33 changed files with 1771 additions and 421 deletions

9
api/plans.go Normal file
View file

@ -0,0 +1,9 @@
package api
type RepeatingPlanStruct struct {
Weekdays []int `json:"weekdays"` // 0-6 (Sunday-Saturday)
Time string `json:"time"` // HH:MM
Tz string `json:"tz"` // timezone in IANA format
Soc int `json:"soc"`
Active bool `json:"active"`
}

View file

@ -632,3 +632,7 @@ html.app .modal-dialog {
.alert-danger code {
color: var(--evcc-darkest-green);
}
input::-webkit-date-and-time-value {
text-align: left;
}

View file

@ -7,7 +7,12 @@
data-testid="charging-plan"
>
<div class="value m-0 d-block align-items-baseline justify-content-center">
<button class="value-button p-0" :class="buttonColor" @click="openModal">
<button
class="value-button p-0"
:class="buttonColor"
data-testid="charging-plan-button"
@click="openModal"
>
<strong v-if="enabled">
<span class="targetTimeLabel"> {{ targetTimeLabel }}</span>
<div
@ -33,6 +38,7 @@
tabindex="-1"
role="dialog"
aria-hidden="true"
data-testid="charging-plan-modal"
>
<div class="modal-dialog modal-dialog-centered" role="document">
<div class="modal-content">
@ -74,11 +80,12 @@
</li>
</ul>
<div v-if="isModalVisible">
<ChargingPlanSettings
<ChargingPlansSettings
v-if="departureTabActive"
v-bind="chargingPlanSettingsProps"
@plan-updated="updatePlan"
@plan-removed="removePlan"
v-bind="chargingPlansSettingsProps"
@static-plan-updated="updateStaticPlan"
@static-plan-removed="removeStaticPlan"
@repeating-plans-updated="updateRepeatingPlans"
/>
<ChargingPlanArrival
v-if="arrivalTabActive"
@ -98,7 +105,7 @@
<script>
import Modal from "bootstrap/js/dist/modal";
import LabelAndValue from "./LabelAndValue.vue";
import ChargingPlanSettings from "./ChargingPlanSettings.vue";
import ChargingPlansSettings from "./ChargingPlansSettings.vue";
import ChargingPlanArrival from "./ChargingPlanArrival.vue";
import formatter from "../mixins/formatter";
@ -110,7 +117,7 @@ const ONE_MINUTE = 60 * 1000;
export default {
name: "ChargingPlan",
components: { LabelAndValue, ChargingPlanSettings, ChargingPlanArrival },
components: { LabelAndValue, ChargingPlansSettings, ChargingPlanArrival },
mixins: [formatter, collector],
props: {
currency: String,
@ -162,12 +169,18 @@ export default {
limitSoc: function () {
return this.vehicle?.limitSoc;
},
plans: function () {
staticPlan: function () {
if (this.socBasedPlanning) {
return this.vehicle?.plans || [];
return this.vehicle?.plan;
}
if (this.planEnergy && this.planTime) {
return [{ energy: this.planEnergy, time: this.planTime }];
return { energy: this.planEnergy, time: this.planTime };
}
return null;
},
repeatingPlans: function () {
if (this.vehicle?.repeatingPlans.length > 0) {
return [...this.vehicle.repeatingPlans];
}
return [];
},
@ -183,8 +196,8 @@ export default {
arrivalTabActive: function () {
return this.activeTab === "arrival";
},
chargingPlanSettingsProps: function () {
return this.collectProps(ChargingPlanSettings);
chargingPlansSettingsProps: function () {
return this.collectProps(ChargingPlansSettings);
},
chargingPlanArrival: function () {
return this.collectProps(ChargingPlanArrival);
@ -211,6 +224,11 @@ export default {
effectivePlanTime() {
this.updateTargetTimeLabel();
},
"$i18n.locale": {
handler() {
this.updateTargetTimeLabel();
},
},
},
mounted() {
this.modal = Modal.getOrCreateInstance(this.$refs.modal);
@ -263,7 +281,7 @@ export default {
showArrivalTab: function () {
this.activeTab = "arrival";
},
updatePlan: function ({ soc, time, energy }) {
updateStaticPlan: function ({ soc, time, energy }) {
const timeISO = time.toISOString();
if (this.socBasedPlanning) {
api.post(`${this.apiVehicle}plan/soc/${soc}/${timeISO}`);
@ -271,13 +289,16 @@ export default {
api.post(`${this.apiLoadpoint}plan/energy/${energy}/${timeISO}`);
}
},
removePlan: function () {
removeStaticPlan: function () {
if (this.socBasedPlanning) {
api.delete(`${this.apiVehicle}plan/soc`);
} else {
api.delete(`${this.apiLoadpoint}plan/energy`);
}
},
updateRepeatingPlans: function (plans) {
api.post(`${this.apiVehicle}plan/repeating`, { plans });
},
setMinSoc: function (soc) {
api.post(`${this.apiVehicle}minsoc/${soc}`);
},

View file

@ -37,8 +37,8 @@ describe("basics", () => {
result = wrapper.vm.slots;
});
test("should return 42 slots", () => {
expect(result.length).eq(42);
test("should return 39 slots", () => {
expect(result.length).eq(39);
});
test("slots should be an hour apart", () => {

View file

@ -25,7 +25,12 @@
</div>
</div>
</div>
<TariffChart :slots="slots" @slot-hovered="slotHovered" />
<TariffChart
:slots="slots"
:target-text="targetText"
:target-offset="targetHourOffset"
@slot-hovered="slotHovered"
/>
</div>
</template>
@ -114,12 +119,28 @@ export default {
}
return null;
},
targetHourOffset() {
if (!this.targetTime) {
return null;
}
const start = new Date(this.startTime);
start.setMinutes(0);
start.setSeconds(0);
start.setMilliseconds(0);
return (this.targetTime.getTime() - start.getTime()) / (60 * 60 * 1000);
},
targetText() {
if (!this.targetTime) {
return null;
}
return this.fmtWeekdayTime(this.targetTime);
},
slots() {
const result = [];
const rates = this.convertDates(this.rates);
const plan = this.convertDates(this.plan);
const oneHour = 60 * 60 * 1000;
for (let i = 0; i < 42; i++) {
for (let i = 0; i < 39; i++) {
const start = new Date(this.startTime.getTime() + oneHour * i);
const startHour = start.getHours();
start.setMinutes(0);
@ -132,13 +153,23 @@ export default {
const toLate = this.targetTime && this.targetTime <= start;
// TODO: handle multiple matching time slots
const price = this.findSlotInRange(start, end, rates)?.price;
const isTarget = start <= this.targetTime && end > this.targetTime;
const charging = this.findSlotInRange(start, end, plan) != null;
const warning =
charging &&
this.targetTime &&
end > this.targetTime &&
this.targetTime < this.endTime;
result.push({ day, price, startHour, endHour, charging, toLate, warning });
result.push({
day,
price,
startHour,
endHour,
charging,
toLate,
warning,
isTarget,
});
}
return result;
},

View file

@ -0,0 +1,244 @@
<template>
<div>
<h5
class="d-flex gap-3 align-items-baseline d-lg-none mb-4 fw-normal evcc-gray"
data-testid="repeating-plan-title"
>
<span class="text-uppercase fs-6">
{{ `${$t("main.chargingPlan.planNumber", { number: `#${number}` })}` }}
</span>
<small>
{{ $t("main.chargingPlan.repeating") }}
</small>
</h5>
<div v-if="showHeader" class="row d-none d-lg-flex mb-2">
<div class="plan-id d-none d-lg-flex"></div>
<div class="col-6 col-lg-3">
<label :for="formId('weekdays')">
{{ $t("main.chargingPlan.weekdays") }}
</label>
</div>
<div class="col-6 col-lg-2">
<label :for="formId('time')">
{{ $t("main.chargingPlan.time") }}
</label>
</div>
<div class="col-6 col-lg-3">
<label :for="formId('goal')">
{{ $t("main.chargingPlan.goal") }}
</label>
</div>
<div class="col-6 col-lg-1">
<label :for="formId('active')"> {{ $t("main.chargingPlan.active") }} </label>
</div>
</div>
<div class="row">
<div class="plan-id d-none d-lg-flex align-items-center justify-content-start fs-6">
#{{ number }}
</div>
<div class="col-5 d-lg-none col-form-label">
<label :for="formId('weekdays')">
{{ $t("main.chargingPlan.weekdays") }}
</label>
</div>
<div class="col-7 col-lg-3 mb-2 mb-lg-0">
<MultiSelect
:id="formId('weekdays')"
:value="selectedWeekdays"
:options="dayOptions"
:selectAllLabel="$t('main.chargingPlan.selectAll')"
data-testid="repeating-plan-weekdays"
@update:model-value="changeSelectedWeekdays"
>
{{ weekdaysLabel }}
</MultiSelect>
</div>
<div class="col-5 d-lg-none col-form-label">
<label :for="formId('time')">
{{ $t("main.chargingPlan.time") }}
</label>
</div>
<div class="col-7 col-lg-2 mb-2 mb-lg-0">
<input
:id="formId('time')"
v-model="selectedTime"
type="time"
class="form-control mx-0 text-start"
:step="60 * 5"
data-testid="repeating-plan-time"
required
@change="update()"
/>
</div>
<div class="col-5 d-lg-none col-form-label">
<label :for="formId('goal')">
{{ $t("main.chargingPlan.goal") }}
</label>
</div>
<div class="col-7 col-lg-3 mb-2 mb-lg-0">
<select
:id="formId('goal')"
v-model="selectedSoc"
class="form-select mx-0"
data-testid="repeating-plan-soc"
@change="update()"
>
<option v-for="opt in socOptions" :key="opt.value" :value="opt.value">
{{ opt.name }}
</option>
</select>
</div>
<div class="col-5 d-lg-none col-form-label">
<label :for="formId('active')">
{{ $t("main.chargingPlan.active") }}
</label>
</div>
<div class="col-2 col-lg-1 d-flex align-items-center">
<div class="form-check form-switch">
<input
:id="formId('active')"
v-model="selectedActive"
class="form-check-input"
type="checkbox"
role="switch"
data-testid="repeating-plan-active"
:checked="selectedActive"
tabindex="0"
@change="update(true)"
/>
</div>
</div>
<div class="col-5 col-lg-2 d-flex align-items-center">
<button
v-if="showApply"
type="button"
class="btn btn-sm btn-outline-primary border-0 text-decoration-underline"
data-testid="repeating-plan-apply"
tabindex="0"
@click="update(true)"
>
{{ $t("main.chargingPlan.update") }}
</button>
<button
v-else
type="button"
class="btn btn-sm btn-outline-secondary border-0"
aria-label="Remove"
tabindex="0"
@click="$emit('removed', id)"
>
<shopicon-regular-trash size="s" class="flex-shrink-0"></shopicon-regular-trash>
</button>
</div>
</div>
</div>
</template>
<script>
import "@h2d2/shopicons/es/regular/trash";
import { distanceUnit } from "../units";
import MultiSelect from "./MultiSelect.vue";
import formatter from "../mixins/formatter";
import deepEqual from "../utils/deepEqual";
export default {
name: "ChargingPlanRepeatingSettings",
components: {
MultiSelect,
},
mixins: [formatter],
props: {
number: Number,
weekdays: { type: Array, default: () => [] },
time: String,
tz: String,
soc: Number,
showHeader: Boolean,
active: Boolean,
rangePerSoc: Number,
formIdPrefix: String,
},
emits: ["updated", "removed"],
data: function () {
return {
selectedWeekdays: this.weekdays,
selectedTime: this.time,
selectedSoc: this.soc,
selectedActive: this.active,
};
},
computed: {
dataChanged: function () {
return (
!deepEqual(this.weekdays, this.selectedWeekdays) ||
this.time !== this.selectedTime ||
this.soc !== this.selectedSoc ||
this.active !== this.selectedActive
);
},
showApply: function () {
return this.dataChanged && this.selectedActive;
},
weekdaysLabel: function () {
return this.getShortenedWeekdaysLabel(this.selectedWeekdays);
},
socOptions: function () {
// a list of entries from 5 to 100 with a step of 5
return Array.from(Array(20).keys())
.map((i) => 5 + i * 5)
.map(this.socOption);
},
dayOptions: function () {
return this.getWeekdaysList("long");
},
},
watch: {
weekdays(newValue, oldValue) {
if (!deepEqual(newValue, oldValue)) {
this.selectedWeekdays = newValue;
}
},
time(newValue) {
this.selectedTime = newValue;
},
soc(newValue) {
this.selectedSoc = newValue;
},
active(newValue) {
this.selectedActive = newValue;
},
},
methods: {
changeSelectedWeekdays: function (weekdays) {
this.selectedWeekdays = weekdays;
this.update();
},
formId: function (name) {
return `${this.formIdPrefix}-${this.number}-${name}`;
},
socOption: function (value) {
const name = this.fmtSocOption(value, this.rangePerSoc, distanceUnit());
return { value, name };
},
update: function (forceSave = false) {
const plan = {
weekdays: this.selectedWeekdays,
time: this.selectedTime,
soc: this.selectedSoc,
tz: this.tz,
active: this.selectedActive,
};
if (forceSave || !this.selectedActive) {
this.$emit("updated", plan);
}
},
},
};
</script>
<style scoped>
.plan-id {
width: 2.5rem;
color: var(--evcc-gray);
}
</style>

View file

@ -1,200 +0,0 @@
<template>
<div class="mt-4">
<div class="form-group d-lg-flex align-items-baseline justify-content-between">
<div class="container px-0 mb-3">
<ChargingPlanSettingsEntry
:id="`${id}_0`"
class="mb-2"
v-bind="plans[0] || {}"
:capacity="capacity"
:range-per-soc="rangePerSoc"
:soc-per-kwh="socPerKwh"
:soc-based-planning="socBasedPlanning"
@plan-updated="(data) => updatePlan({ index: 0, ...data })"
@plan-removed="() => removePlan(0)"
@plan-preview="previewPlan"
/>
</div>
</div>
<ChargingPlanWarnings v-bind="chargingPlanWarningsProps" class="mb-4" />
<hr />
<h5>
<div class="inner" data-testid="plan-preview-title">
{{ $t(`main.targetCharge.${isPreview ? "preview" : "currentPlan"}`) }}
</div>
</h5>
<ChargingPlanPreview v-bind="chargingPlanPreviewProps" />
</div>
</template>
<script>
import ChargingPlanPreview from "./ChargingPlanPreview.vue";
import ChargingPlanSettingsEntry from "./ChargingPlanSettingsEntry.vue";
import ChargingPlanWarnings from "./ChargingPlanWarnings.vue";
import formatter from "../mixins/formatter";
import collector from "../mixins/collector";
import api from "../api";
import deepEqual from "../utils/deepEqual";
const DEFAULT_TARGET_TIME = "7:00";
const LAST_TARGET_TIME_KEY = "last_target_time";
export default {
name: "ChargingPlanSettings",
components: { ChargingPlanPreview, ChargingPlanSettingsEntry, ChargingPlanWarnings },
mixins: [formatter, collector],
props: {
id: [String, Number],
plans: { type: Array, default: () => [] },
effectiveLimitSoc: Number,
effectivePlanTime: String,
effectivePlanSoc: Number,
planEnergy: Number,
limitEnergy: Number,
socBasedPlanning: Boolean,
socPerKwh: Number,
rangePerSoc: Number,
smartCostLimit: Number,
smartCostType: String,
currency: String,
mode: String,
capacity: Number,
vehicle: Object,
vehicleLimitSoc: Number,
planOverrun: Number,
},
emits: ["plan-removed", "plan-updated"],
data: function () {
return {
tariff: {},
plan: {},
activeTab: "time",
isPreview: false,
debounceTimer: null,
};
},
computed: {
chargingPlanWarningsProps: function () {
return this.collectProps(ChargingPlanWarnings);
},
chargingPlanPreviewProps: function () {
const { rates } = this.tariff;
const { duration, plan, power, planTime } = this.plan;
const targetTime = planTime ? new Date(planTime) : null;
const { currency, smartCostType } = this;
return rates
? { duration, plan, power, rates, targetTime, currency, smartCostType }
: null;
},
},
watch: {
plans(newPlans, oldPlans) {
if (!deepEqual(newPlans, oldPlans) && newPlans.length > 0) {
this.fetchPlanDebounced();
}
},
},
mounted() {
if (this.plans.length > 0) {
this.fetchPlanDebounced();
}
},
methods: {
fetchActivePlan: async function () {
return await api.get(`loadpoints/${this.id}/plan`);
},
fetchPlanPreviewSoc: async function (soc, time) {
const timeISO = time.toISOString();
return await api.get(`loadpoints/${this.id}/plan/preview/soc/${soc}/${timeISO}`);
},
fetchPlanPreviewEnergy: async function (energy, time) {
const timeISO = time.toISOString();
return await api.get(`loadpoints/${this.id}/plan/preview/energy/${energy}/${timeISO}`);
},
fetchPlan: async function (preview) {
try {
let planRes = null;
if (preview && this.socBasedPlanning) {
planRes = await this.fetchPlanPreviewSoc(preview.soc, preview.time);
this.isPreview = true;
} else if (preview && !this.socBasedPlanning) {
planRes = await this.fetchPlanPreviewEnergy(preview.energy, preview.time);
this.isPreview = true;
} else {
planRes = await this.fetchActivePlan();
this.isPreview = false;
}
this.plan = planRes.data.result;
const tariffRes = await api.get(`tariff/planner`, {
validateStatus: function (status) {
return status >= 200 && status < 500;
},
});
this.tariff = tariffRes.status === 404 ? { rates: [] } : tariffRes.data.result;
} catch (e) {
console.error(e);
}
},
fetchPlanDebounced: async function (preview) {
if (!this.debounceTimer) {
await this.fetchPlan(preview);
return;
}
clearTimeout(this.debounceTimer);
this.debounceTimer = setTimeout(async () => await this.fetchPlan(preview), 1000);
},
defaultDate: function () {
const [hours, minutes] = (
window.localStorage[LAST_TARGET_TIME_KEY] || DEFAULT_TARGET_TIME
).split(":");
const target = new Date();
target.setSeconds(0);
target.setMinutes(minutes);
target.setHours(hours);
// today or tomorrow?
const isInPast = target < new Date();
if (isInPast) {
target.setDate(target.getDate() + 1);
}
return target;
},
addPlan: function () {
this.$emit("plan-updated", {
time: this.defaultDate(),
soc: 100,
energy: this.capacity || 10,
});
},
removePlan: function (index) {
this.$emit("plan-removed", index);
},
updatePlan: function (data) {
this.$emit("plan-updated", data);
},
previewPlan: function (data) {
this.fetchPlanDebounced(data);
},
},
};
</script>
<style scoped>
h5 {
position: relative;
display: flex;
top: -25px;
margin-bottom: -0.5rem;
padding: 0 0.5rem;
justify-content: center;
}
h5 .inner {
padding: 0 0.5rem;
background-color: var(--evcc-box);
font-weight: normal;
color: var(--evcc-gray);
text-transform: uppercase;
text-align: center;
}
</style>

View file

@ -1,7 +1,18 @@
<template>
<div>
<div class="mb-5 mb-lg-4" data-testid="plan-entry">
<h5
v-if="multiplePlans"
class="d-flex gap-3 align-items-baseline d-lg-none mb-4 fw-normal evcc-gray"
data-testid="repeating-plan-title"
>
<span class="text-uppercase fs-6">
{{ `${$t("main.chargingPlan.planNumber", { number: "#1" })}` }}
</span>
</h5>
<div class="row d-none d-lg-flex mb-2">
<div class="col-6 col-lg-4">
<div v-if="multiplePlans" class="plan-id d-none d-lg-flex"></div>
<div class="col-6" :class="multiplePlans ? 'col-lg-3' : 'col-lg-4'">
<label :for="formId('day')">
{{ $t("main.chargingPlan.day") }}
</label>
@ -21,18 +32,24 @@
</div>
</div>
<div class="row">
<div
v-if="multiplePlans"
class="plan-id d-none d-lg-flex align-items-center justify-content-start fs-6"
>
#1
</div>
<div class="col-5 d-lg-none col-form-label">
<label :for="formId('day')">
{{ $t("main.chargingPlan.day") }}
</label>
</div>
<div class="col-7 col-lg-4 mb-2 mb-lg-0">
<div :class="['col-7', multiplePlans ? 'col-lg-3' : 'col-lg-4', 'mb-2', 'mb-lg-0']">
<select
:id="formId('day')"
v-model="selectedDay"
class="form-select me-2"
data-testid="plan-day"
@change="preview"
data-testid="static-plan-day"
@change="preview()"
>
<option v-for="opt in dayOptions()" :key="opt.value" :value="opt.value">
{{ opt.name }}
@ -51,9 +68,9 @@
type="time"
class="form-control mx-0"
:step="60 * 5"
data-testid="plan-time"
data-testid="static-plan-time"
required
@change="preview"
@change="preview()"
/>
</div>
<div class="col-5 d-lg-none col-form-label">
@ -67,8 +84,8 @@
:id="formId('goal')"
v-model="selectedSoc"
class="form-select mx-0"
data-testid="plan-soc"
@change="preview"
data-testid="static-plan-soc"
@change="preview()"
>
<option v-for="opt in socOptions" :key="opt.value" :value="opt.value">
{{ opt.name }}
@ -79,8 +96,8 @@
:id="formId('goal')"
v-model="selectedEnergy"
class="form-select mx-0"
data-testid="plan-energy"
@change="preview"
data-testid="static-plan-energy"
@change="preview()"
>
<option v-for="opt in energyOptions" :key="opt.energy" :value="opt.energy">
{{ opt.text }}
@ -92,25 +109,29 @@
{{ $t("main.chargingPlan.active") }}
</label>
</div>
<div class="col-2 d-flex align-items-center justify-content-start">
<div class="col-2 col-lg-1 d-flex align-items-center">
<div class="form-check form-switch">
<input
:id="formId('active')"
class="form-check-input"
type="checkbox"
role="switch"
data-testid="plan-active"
data-testid="static-plan-active"
:checked="!isNew"
:disabled="timeInThePast"
tabindex="0"
@change="toggle"
/>
</div>
</div>
<div class="col-5 col-lg-2 d-flex align-items-center">
<button
v-if="dataChanged && !isNew"
type="button"
class="btn btn-sm btn-outline-primary ms-3 border-0 text-decoration-underline"
data-testid="plan-apply"
class="btn btn-sm btn-outline-primary border-0 text-decoration-underline"
data-testid="static-plan-apply"
:disabled="timeInThePast"
tabindex="0"
@click="update"
>
{{ $t("main.chargingPlan.update") }}
@ -138,7 +159,7 @@ const LAST_ENERGY_GOAL_KEY = "last_energy_goal";
const DEFAULT_TARGET_TIME = "7:00";
export default {
name: "ChargingPlanSettingsEntry",
name: "ChargingPlanStaticSettings",
mixins: [formatter],
props: {
id: String,
@ -149,15 +170,16 @@ export default {
socPerKwh: Number,
capacity: Number,
socBasedPlanning: Boolean,
multiplePlans: Boolean,
},
emits: ["plan-updated", "plan-removed", "plan-preview"],
emits: ["static-plan-updated", "static-plan-removed", "plan-preview"],
data: function () {
return {
selectedDay: null,
selectedTime: null,
selectedSoc: this.soc,
selectedEnergy: this.energy,
enabled: false,
active: false,
};
},
computed: {
@ -224,9 +246,13 @@ export default {
this.selectedEnergy = this.energy;
}
},
isNew(value) {
this.active = !value;
},
},
mounted() {
this.initInputFields();
this.preview();
},
methods: {
formId: function (name) {
@ -249,7 +275,6 @@ export default {
if (!time) {
// no time but existing selection, keep it
if (this.selectedDay && this.selectedTime) {
this.preview();
return;
}
time = this.defaultTime();
@ -257,10 +282,6 @@ export default {
const date = new Date(time);
this.selectedDay = this.fmtDayString(date);
this.selectedTime = this.fmtTimeString(date);
if (this.isNew || this.dataChanged) {
this.preview();
}
},
dayOptions: function () {
const options = [];
@ -271,11 +292,11 @@ export default {
];
for (let i = 0; i < 7; i++) {
const dayNumber = date.toLocaleDateString(this.$i18n?.locale, {
month: "short",
day: "numeric",
month: "short",
});
const dayName =
labels[i] || date.toLocaleDateString(this.$i18n?.locale, { weekday: "long" });
labels[i] || date.toLocaleDateString(this.$i18n?.locale, { weekday: "short" });
options.push({
value: this.fmtDayString(date),
name: `${dayNumber} (${dayName})`,
@ -298,13 +319,16 @@ export default {
} catch (e) {
console.warn(e);
}
this.$emit("plan-updated", {
this.$emit("static-plan-updated", {
time: this.selectedDate,
soc: this.selectedSoc,
energy: this.selectedEnergy,
});
},
preview: function () {
preview: function (force = false) {
if (!this.isNew && !force) {
return false;
}
this.$emit("plan-preview", {
time: this.selectedDate,
soc: this.selectedSoc,
@ -316,9 +340,10 @@ export default {
if (checked) {
this.update();
} else {
this.$emit("plan-removed");
this.$emit("static-plan-removed");
this.preview(true);
}
this.enabled = checked;
this.active = checked;
},
defaultTime: function () {
const [hours, minutes] = (
@ -339,3 +364,9 @@ export default {
},
};
</script>
<style scoped>
.plan-id {
width: 2.5rem;
color: var(--evcc-gray);
}
</style>

View file

@ -1,15 +1,15 @@
<template>
<p class="mb-0" data-testid="plan-warnings">
<span v-if="targetIsAboveLimit" class="d-block text-secondary mb-1">
<p class="mb-3 root" data-testid="plan-warnings">
<span v-if="targetIsAboveLimit" class="d-block evcc-gray mb-1">
{{ $t("main.targetCharge.targetIsAboveLimit", { limit: limitFmt }) }}
</span>
<span v-if="['off', 'now'].includes(mode)" class="d-block text-secondary mb-1">
<span v-if="['off', 'now'].includes(mode)" class="d-block evcc-gray mb-1">
{{ $t("main.targetCharge.onlyInPvMode") }}
</span>
<span v-if="timeTooFarInTheFuture" class="d-block text-secondary mb-1">
<span v-if="timeTooFarInTheFuture" class="d-block evcc-gray mb-1">
{{ $t("main.targetCharge.targetIsTooFarInTheFuture") }}
</span>
<span v-if="costLimitExists" class="d-block text-secondary mb-1">
<span v-if="costLimitExists" class="d-block evcc-gray mb-1">
{{ $t("main.targetCharge.costLimitIgnore", { limit: costLimitText }) }}
</span>
<span v-if="notReachableInTime" class="d-block text-warning mb-1">
@ -132,3 +132,9 @@ export default {
},
};
</script>
<style scoped>
.root:empty {
display: none;
}
</style>

View file

@ -0,0 +1,94 @@
<template>
<div v-for="(plan, index) in plans" :key="index" data-testid="plan-entry">
<div>
<ChargingPlanRepeatingSettings
:showHeader="index === 0"
:number="index + 2"
class="mb-5 mb-lg-4"
:formIdPrefix="formIdPrefix"
v-bind="plan"
:rangePerSoc="rangePerSoc"
@updated="updatePlan(index, $event)"
@saved="savePlan(index, $event)"
@removed="removePlan(index)"
/>
</div>
</div>
<div class="d-flex align-items-center pb-4">
<button
type="button"
class="d-flex btn btn-sm btn-outline-secondary border-0 align-items-center gap-2 evcc-gray"
data-testid="repeating-plan-add"
tabindex="0"
@click="addPlan"
>
<shopicon-regular-plus size="s" class="flex-shrink-0"></shopicon-regular-plus>
{{ $t("main.chargingPlan.addRepeatingPlan") }}
</button>
</div>
</template>
<script>
import ChargingPlanRepeatingSettings from "./ChargingPlanRepeatingSettings.vue";
import deepEqual from "../utils/deepEqual";
import formatter from "../mixins/formatter";
const DEFAULT_WEEKDAYS = [1, 2, 3, 4, 5]; // weekdays
const DEFAULT_TARGET_TIME = "07:00";
const DEFAULT_TARGET_SOC = 80;
export default {
name: "ChargingPlansRepeatingSettings",
components: {
ChargingPlanRepeatingSettings,
},
mixins: [formatter],
props: {
id: Number,
rangePerSoc: Number,
plans: { type: Array, default: () => [] },
},
emits: ["updated"],
computed: {
formIdPrefix: function () {
return `chargingplan-lp${this.id}`;
},
},
methods: {
deepEqual,
addPlan: function () {
const newPlan = {
weekdays: DEFAULT_WEEKDAYS,
time: DEFAULT_TARGET_TIME,
soc: DEFAULT_TARGET_SOC,
active: false,
tz: this.timezone(),
};
// update the plan without storing non-applied changes from other plans
const plans = [...this.plans]; // clone array
plans.push(newPlan);
this.updatePlans(plans);
},
updatePlan: function (index, plan) {
const plans = [...this.plans]; // clone array
plans.splice(index, 1, plan);
this.updatePlans(plans);
},
updatePlans: function (plans) {
this.$emit("updated", plans);
},
removePlan: function (index) {
const plans = [...this.plans]; // clone array
plans.splice(index, 1);
this.updatePlans(plans);
},
},
};
</script>
<style scoped>
.btn-outline-secondary {
margin-left: -0.5rem;
}
</style>

View file

@ -0,0 +1,338 @@
<template>
<div class="mt-4">
<div class="form-group d-lg-flex align-items-baseline justify-content-between">
<div class="container px-0">
<ChargingPlanStaticSettings
:id="`lp${id}-1`"
class="mb-2"
v-bind="staticPlan || {}"
:capacity="capacity"
:range-per-soc="rangePerSoc"
:soc-per-kwh="socPerKwh"
:soc-based-planning="socBasedPlanning"
:multiplePlans="multiplePlans"
@static-plan-updated="(data) => updateStaticPlan({ index: 0, ...data })"
@static-plan-removed="() => removeStaticPlan(0)"
@plan-preview="previewStaticPlan"
/>
<div v-if="socBasedPlanning">
<div v-if="multiplePlans" class="d-none d-lg-block">
<hr class="mt-5" />
<h5>
<div class="inner mb-3" data-testid="repeating-plan-title">
{{ $t("main.chargingPlan.repeatingPlans") }}
</div>
</h5>
</div>
<ChargingPlansRepeatingSettings
:id="id"
:rangePerSoc="rangePerSoc"
:plans="repeatingPlans"
@updated="updateRepeatingPlans"
/>
</div>
</div>
</div>
<hr />
<h5>
<div class="inner" data-testid="plan-preview-title">
<span v-if="!multiplePlans">
{{ $t(`main.targetCharge.${noActivePlan ? "preview" : "currentPlan"}`) }}
</span>
<CustomSelect
v-else-if="noActivePlan"
:options="previewPlanOptions"
:selected="selectedPreviewId"
data-testid="preview-plan-select"
@change="selectPreviewPlan($event.target.value)"
>
<span class="text-decoration-underline">
{{ selectedPreviewPlanTitle }}
</span>
</CustomSelect>
<span v-else>
{{ nextPlanTitle }}
</span>
</div>
</h5>
<ChargingPlanWarnings v-bind="chargingPlanWarningsProps" />
<ChargingPlanPreview v-bind="chargingPlanPreviewProps" />
</div>
</template>
<script>
import "@h2d2/shopicons/es/regular/plus";
import ChargingPlanPreview from "./ChargingPlanPreview.vue";
import ChargingPlanStaticSettings from "./ChargingPlanStaticSettings.vue";
import ChargingPlansRepeatingSettings from "./ChargingPlansRepeatingSettings.vue";
import ChargingPlanWarnings from "./ChargingPlanWarnings.vue";
import formatter from "../mixins/formatter";
import collector from "../mixins/collector";
import api from "../api";
import CustomSelect from "./CustomSelect.vue";
import deepEqual from "../utils/deepEqual";
const TARIFF_CACHE_TIME = 300000; // 5 minutes
export default {
name: "ChargingPlansSettings",
components: {
ChargingPlanPreview,
ChargingPlanStaticSettings,
ChargingPlansRepeatingSettings,
ChargingPlanWarnings,
CustomSelect,
},
mixins: [formatter, collector],
props: {
id: [String, Number],
staticPlan: Object,
repeatingPlans: { type: Array, default: () => [] },
effectiveLimitSoc: Number,
effectivePlanTime: String,
effectivePlanSoc: Number,
planEnergy: Number,
limitEnergy: Number,
socBasedPlanning: Boolean,
socPerKwh: Number,
rangePerSoc: Number,
smartCostLimit: Number,
smartCostType: String,
currency: String,
mode: String,
capacity: Number,
vehicle: Object,
vehicleLimitSoc: Number,
planOverrun: Number,
},
emits: ["static-plan-removed", "static-plan-updated", "repeating-plans-updated"],
data: function () {
return {
staticPlanPreview: {},
tariff: {},
plan: {},
activeTab: "time",
debounceTimer: null,
selectedPreviewId: 1,
nextPlanId: 0,
};
},
computed: {
noActivePlan: function () {
return !this.staticPlan && this.repeatingPlans.every((plan) => !plan.active);
},
multiplePlans: function () {
return this.repeatingPlans.length !== 0;
},
selectedPreviewPlanTitle: function () {
return this.previewPlanOptions[this.selectedPreviewId - 1]?.name;
},
chargingPlanWarningsProps: function () {
return this.collectProps(ChargingPlanWarnings);
},
chargingPlanPreviewProps: function () {
const { rates } = this.tariff;
const { duration, plan, power, planTime } = this.plan;
const targetTime = planTime ? new Date(planTime) : null;
const { currency, smartCostType } = this;
return rates
? { duration, plan, power, rates, targetTime, currency, smartCostType }
: null;
},
previewPlanOptions: function () {
const name = (number) => `${this.$t("main.targetCharge.preview")} #${number}`;
// static plan
const options = [{ value: 1, name: name(1) }];
// repeating plans
this.repeatingPlans.forEach((plan, index) => {
const number = index + 2;
options.push({
value: number,
name: name(number),
disabled: !plan.weekdays.length,
});
});
return options;
},
nextPlanTitle: function () {
return `${this.$t("main.targetCharge.nextPlan")} #${this.nextPlanId}`;
},
},
watch: {
effectivePlanTime(newValue) {
if (null !== newValue) {
this.fetchPlanDebounced();
}
},
staticPlan: {
deep: true,
handler: function (vNew, vOld) {
if (!deepEqual(vNew, vOld)) {
this.fetchPlanDebounced();
}
},
},
repeatingPlans: {
deep: true,
handler: function (vNew, vOld) {
if (!deepEqual(vNew, vOld)) {
this.adjustPreviewId();
this.fetchPlanDebounced();
}
},
},
},
mounted() {
this.fetchPlanDebounced();
},
methods: {
selectPreviewPlan: function (id) {
this.selectedPreviewId = id;
this.fetchPlanPreviewDebounced();
},
fetchPlanDebounced: async function () {
if (this.noActivePlan) {
await this.fetchPlanPreviewDebounced();
} else {
await this.fetchActivePlanDebounced();
}
},
adjustPreviewId: function () {
if (this.selectedPreviewId > this.previewPlanOptions.length) {
this.selectedPreviewId = this.previewPlanOptions.length;
}
},
fetchActivePlan: async function () {
try {
const res = await api.get(`loadpoints/${this.id}/plan`);
this.plan = res.data.result;
this.nextPlanId = this.plan.planId;
} catch (e) {
console.error(e);
}
await this.updateTariff();
},
fetchStaticPreviewSoc: async function (soc, time) {
const timeISO = time.toISOString();
return await api.get(`loadpoints/${this.id}/plan/static/preview/soc/${soc}/${timeISO}`);
},
fetchRepeatingPreview: async function (weekdays, soc, time, tz) {
return await api.get(
`loadpoints/${this.id}/plan/repeating/preview/${soc}/${weekdays}/${time}/${encodeURIComponent(tz)}`
);
},
fetchStaticPreviewEnergy: async function (energy, time) {
const timeISO = time.toISOString();
return await api.get(
`loadpoints/${this.id}/plan/static/preview/energy/${energy}/${timeISO}`
);
},
fetchPreviewPlan: async function () {
// only show preview of no plan is active
if (!this.noActivePlan) return;
try {
let planRes = undefined;
if (this.selectedPreviewId < 2 && this.staticPlanPreview) {
// static plan
const { soc, energy, time } = this.staticPlanPreview;
if (this.socBasedPlanning) {
planRes = await this.fetchStaticPreviewSoc(soc, new Date(time));
} else {
planRes = await this.fetchStaticPreviewEnergy(energy, new Date(time));
}
} else {
// repeating plan
const plan = this.repeatingPlans[this.selectedPreviewId - 2];
if (!plan) {
return;
}
const { weekdays, soc, time, tz } = plan;
if (weekdays.length === 0) {
return;
}
planRes = await this.fetchRepeatingPreview(weekdays, soc, time, tz);
}
this.plan = planRes.data.result;
await this.updateTariff();
} catch (e) {
console.error(e);
}
},
updateTariff: async function () {
// cache tariff for 5 minutes
if (
this.tariff?.lastUpdate &&
Date.now() - this.tariff.lastUpdate.getTime() <= TARIFF_CACHE_TIME
) {
return;
}
const tariffRes = await api.get(`tariff/planner`, {
validateStatus: function (status) {
return status >= 200 && status < 500;
},
});
if (tariffRes.status === 404) {
this.tariff = { rates: [] };
} else {
this.tariff = tariffRes.data.result;
this.tariff.lastUpdate = new Date();
}
},
fetchPlanPreviewDebounced: async function () {
if (!this.debounceTimer) {
await this.fetchPreviewPlan();
return;
}
clearTimeout(this.debounceTimer);
this.debounceTimer = setTimeout(async () => await this.fetchPreviewPlan(), 1000);
},
fetchActivePlanDebounced: async function () {
if (!this.debounceTimer) {
await this.fetchActivePlan();
return;
}
clearTimeout(this.debounceTimer);
this.debounceTimer = setTimeout(async () => await this.fetchActivePlan(), 1000);
},
removeStaticPlan: function (index) {
this.$emit("static-plan-removed", index);
},
updateStaticPlan: function (data) {
this.$emit("static-plan-updated", data);
},
updateRepeatingPlans: function (plans) {
this.$emit("repeating-plans-updated", plans);
},
previewStaticPlan: function (plan) {
this.staticPlanPreview = plan;
this.fetchPlanPreviewDebounced();
},
},
};
</script>
<style scoped>
h5 {
position: relative;
display: flex;
top: -25px;
margin-bottom: -0.5rem;
padding: 0 0.5rem;
justify-content: center;
}
h5 .inner {
padding: 0 0.5rem;
background-color: var(--evcc-box);
font-weight: normal;
color: var(--evcc-gray);
text-transform: uppercase;
text-align: center;
}
</style>

View file

@ -1,6 +1,6 @@
<template>
<label class="position-relative d-block" :for="id" role="button">
<select :id="id" :value="selected" class="custom-select" @change="change">
<label class="root position-relative d-block" :for="id" role="button">
<select :id="id" :value="selected" class="custom-select" tabindex="0" @change="change">
<option
v-for="{ name, value, count, disabled } in options"
:key="value"
@ -47,4 +47,12 @@ export default {
opacity: 0;
-webkit-appearance: menulist-button;
}
.root {
border-radius: var(--bs-border-radius);
}
.root:focus-within {
outline: var(--bs-focus-ring-width) solid var(--bs-focus-ring-color);
outline-width: var(--bs-focus-ring-width);
outline-offset: var(--bs-focus-ring-width);
}
</style>

View file

@ -7,17 +7,20 @@
data-bs-toggle="dropdown"
aria-expanded="false"
data-bs-auto-close="outside"
tabindex="0"
>
<slot></slot>
</button>
<ul ref="dropdown" class="dropdown-menu dropdown-menu-end" :aria-labelledby="id">
<template v-if="selectAllLabel">
<li class="dropdown-item p-0">
<label class="form-check px-3 py-2">
<label class="form-check px-3 py-2" :for="formId('all')">
<input
:id="formId('all')"
class="form-check-input ms-0 me-2"
type="checkbox"
value="all"
tabindex="0"
:checked="allOptionsSelected"
@change="toggleCheckAll()"
/>
@ -27,13 +30,14 @@
<li><hr class="dropdown-divider" /></li>
</template>
<li v-for="option in options" :key="option.value" class="dropdown-item p-0">
<label class="form-check px-3 py-2 d-flex" :for="option.value">
<label class="form-check px-3 py-2 d-flex" :for="formId(option.value)">
<input
:id="option.value"
:id="formId(option.value)"
v-model="internalValue"
class="form-check-input ms-0 me-2"
type="checkbox"
:value="option.value"
tabindex="0"
/>
<div class="form-check-label">
{{ option.name }}
@ -46,7 +50,7 @@
<script>
import Dropdown from "bootstrap/js/dist/dropdown";
import deepEqual from "../utils/deepEqual";
export default {
name: "MultiSelect",
props: {
@ -73,35 +77,17 @@ export default {
options: {
immediate: true,
handler(newOptions) {
// If value is empty, set internalValue to include all options
if (this.value.length === 0) {
this.internalValue = newOptions.map((option) => option.value);
} else {
// Otherwise, keep selected options that still exist in the new options
this.internalValue = this.internalValue.filter((value) =>
newOptions.some((option) => option.value === value)
);
}
this.internalValue = this.internalValue.filter((value) =>
newOptions.some((option) => option.value === value)
);
this.$nextTick(() => {
Dropdown.getOrCreateInstance(this.$refs.dropdown).update();
});
},
},
value: {
immediate: true,
handler(newValue) {
this.internalValue =
newValue.length === 0 && this.options.length > 0
? this.options.map((o) => o.value)
: [...newValue];
},
},
internalValue(newValue) {
if (this.allOptionsSelected || this.noneSelected) {
this.$emit("update:modelValue", []);
} else {
this.$emit("update:modelValue", newValue);
}
internalValue(newValue, oldValue) {
if (deepEqual(newValue, oldValue)) return;
this.$emit("update:modelValue", newValue);
},
},
mounted() {
@ -114,6 +100,9 @@ export default {
open() {
this.$emit("open");
},
formId(name) {
return `${this.id}-${name}`;
},
toggleCheckAll() {
if (this.allOptionsSelected) {
this.internalValue = [];

View file

@ -1,47 +1,75 @@
<template>
<div class="chart">
<div
v-for="(slot, index) in slots"
:key="slot.start"
:data-index="index"
class="slot user-select-none"
:class="{
active: slot.charging,
hovered: activeIndex === index,
toLate: slot.toLate,
warning: slot.warning,
'cursor-pointer': slot.selectable,
faded: activeIndex !== null && activeIndex !== index,
}"
@touchstart="startLongPress(index)"
@touchend="cancelLongPress"
@touchmove="cancelLongPress"
@mouseenter="hoverSlot(index)"
@mouseleave="hoverSlot(null)"
@mouseup="hoverSlot(null)"
@click="selectSlot(index)"
>
<div class="slot-bar" :style="priceStyle(slot.price)">
<span v-if="slot.price === undefined && avgPrice" class="unknown">?</span>
<div class="root position-relative">
<div class="chart position-relative">
<div
v-for="(slot, index) in slots"
:key="slot.start"
:data-index="index"
class="slot user-select-none"
:class="{
active: slot.charging,
hovered: activeIndex === index,
toLate: slot.toLate,
warning: slot.warning,
'cursor-pointer': slot.selectable,
faded: activeIndex !== null && activeIndex !== index,
}"
@touchstart="startLongPress(index)"
@touchend="cancelLongPress"
@touchmove="cancelLongPress"
@mouseenter="hoverSlot(index)"
@mouseleave="hoverSlot(null)"
@mouseup="hoverSlot(null)"
@click="selectSlot(index)"
>
<div class="slot-bar" :style="priceStyle(slot.price)">
<span v-if="slot.price === undefined && avgPrice" class="unknown">?</span>
</div>
<div class="slot-label">
<span v-if="!slot.isTarget || targetNearlyOutOfRange">{{
slot.startHour
}}</span>
<br />
<span v-if="showWeekday(index)">{{ slot.day }}</span>
</div>
</div>
<div class="slot-label">
{{ slot.startHour }}
<br />
<span v-if="slot.startHour === 0">{{ slot.day }}</span>
<div
v-if="targetText && !targetNearlyOutOfRange"
class="target-inline"
:class="{ 'target-inline--faded': activeIndex !== null }"
:style="{ left: targetLeft }"
>
<div class="target-inline-marker"></div>
<div class="text-nowrap target-inline-text" data-testid="target-text">
{{ targetText }}
</div>
</div>
</div>
<div v-if="targetText && targetNearlyOutOfRange" ref="targetFixed" class="target-fixed">
<div class="text-nowrap" data-testid="target-text">{{ targetText }}</div>
<PlanEndIcon v-if="targetOutOfRange" />
</div>
</div>
</template>
<script>
import "@h2d2/shopicons/es/regular/arrowright";
import PlanEndIcon from "./MaterialIcon/PlanEnd.vue";
import formatter from "../mixins/formatter";
import { CO2_TYPE } from "../units";
const BAR_WIDTH = 20;
export default {
name: "TariffChart",
components: {
PlanEndIcon,
},
mixins: [formatter],
props: {
slots: Array,
targetText: String,
targetOffset: Number,
},
emits: ["slot-hovered", "slot-selected"],
data() {
@ -65,6 +93,17 @@ export default {
});
return { min, range: max - min };
},
targetLeft() {
const fullHours = Math.floor(this.targetOffset);
const hourFraction = this.targetOffset - fullHours;
return `${fullHours * BAR_WIDTH + 4 + hourFraction * 12}px`;
},
targetNearlyOutOfRange() {
return this.targetOffset > this.slots.length - 4;
},
targetOutOfRange() {
return this.targetOffset > this.slots.length;
},
avgPrice() {
let sum = 0;
let count = 0;
@ -97,6 +136,21 @@ export default {
this.$emit("slot-selected", index);
}
},
showWeekday(index) {
const slot = this.slots[index];
if (!slot) {
return false;
}
for (let i = 0; i < 7; i++) {
if (this.slots[index - i]?.isTarget) {
return false;
}
}
if (slot.startHour === 0) {
return true;
}
return false;
},
priceStyle(price) {
const value = price === undefined ? this.avgPrice : price;
const height =
@ -122,10 +176,47 @@ export default {
<style scoped>
.chart {
display: flex;
height: 140px;
height: 150px;
overflow-x: auto;
align-items: flex-end;
overflow-y: none;
padding-bottom: 45px;
padding-bottom: 55px;
}
.target-inline {
height: 130px;
position: absolute;
top: 0;
display: flex;
align-items: flex-end;
color: var(--bs-primary);
opacity: 1;
transition-property: opacity, color, left;
transition-duration: var(--evcc-transition-fast);
pointer-events: none;
transition-timing-function: ease-in;
}
.target-inline--faded {
opacity: 0.33;
}
.target-fixed {
position: absolute;
background-color: var(--evcc-box);
color: var(--bs-primary);
padding-left: 1rem;
right: 0;
top: 110px;
display: flex;
gap: 0.25rem;
align-items: center;
}
.target-inline-marker {
height: 100%;
border: 1px solid var(--bs-primary);
border-width: 0 0 1px 1px;
width: 0.75rem;
height: 1.5rem;
margin-right: 0.25rem;
margin-bottom: 11px;
}
.slot {
text-align: center;
@ -139,26 +230,18 @@ export default {
transition-property: opacity, background, color;
transition-duration: var(--evcc-transition-fast);
transition-timing-function: ease-in;
width: 20px;
flex-grow: 0;
flex-shrink: 0;
}
@media (max-width: 991px) {
.chart {
overflow-x: auto;
}
.slot {
width: 20px;
flex-grow: 0;
flex-shrink: 0;
}
}
@media (min-width: 992px) {
.chart {
overflow-x: none;
justify-content: stretch;
}
.slot {
flex-grow: 1;
flex-shrink: 1;
flex-basis: 1;
overflow-x: hidden;
}
}
.slot-bar {

View file

@ -210,6 +210,13 @@ export default {
minute: "numeric",
}).format(date);
},
fmtWeekdayTime: function (date) {
return new Intl.DateTimeFormat(this.$i18n?.locale, {
weekday: "short",
hour: "numeric",
minute: "numeric",
}).format(date);
},
fmtMonthYear: function (date) {
return new Intl.DateTimeFormat(this.$i18n?.locale, {
month: "long",
@ -289,6 +296,9 @@ export default {
}
return price;
},
timezone: function () {
return Intl?.DateTimeFormat?.().resolvedOptions?.().timeZone || "UTC";
},
pricePerKWhUnit: function (currency = "EUR", short = false) {
const unit = ENERGY_PRICE_IN_SUBUNIT[currency] || currency;
return `${unit}${short ? "" : "/kWh"}`;
@ -335,5 +345,63 @@ export default {
// TODO: handle fahrenheit
return this.fmtNumber(value, 1, "celsius");
},
getWeekdaysList: function (weekdayFormat) {
const { format } = new Intl.DateTimeFormat(this.$i18n?.locale, { weekday: weekdayFormat });
const mondayToSaturday = [7, 8, 9, 10, 11, 12].map((day, index) => {
return { name: format(new Date(Date.UTC(2021, 5, day))), value: index + 1 };
});
const sunday = { name: format(new Date(Date.UTC(2021, 5, 6))), value: 0 };
return [...mondayToSaturday, sunday];
},
getShortenedWeekdaysLabel: function (selectedWeekdays) {
if (0 === selectedWeekdays.length) {
return "–";
}
const weekdays = this.getWeekdaysList("short");
let label = "";
// the week in the input-parameter starts with 0 for sunday and ends with 6 for saturday
// this algorithms works only if the week starts with 1 for monday and ends with 7 for sunday because
// then we are able to count from 1 to 7 by incrementing the number
// so we have to transform the input accordingly
const selectedWeekdaysTransformed = selectedWeekdays.map(function (dayIndex) {
return 0 === dayIndex ? 7 : dayIndex;
});
function getWeekdayName(dayIndex) {
return weekdays.find((day) => day.value === (7 === dayIndex ? 0 : dayIndex)).name;
}
let maxWeekday = Math.max(...selectedWeekdaysTransformed);
for (let weekdayRangeStart = 1; weekdayRangeStart < 8; weekdayRangeStart++) {
if (selectedWeekdaysTransformed.includes(weekdayRangeStart)) {
label += getWeekdayName(weekdayRangeStart);
let weekdayRangeEnd = weekdayRangeStart;
while (selectedWeekdaysTransformed.includes(weekdayRangeEnd + 1)) {
weekdayRangeEnd++;
}
if (weekdayRangeEnd - weekdayRangeStart > 1) {
// more than 2 consecutive weekdays selected
label += " – " + getWeekdayName(weekdayRangeEnd);
weekdayRangeStart = weekdayRangeEnd;
if (maxWeekday !== weekdayRangeEnd) {
label += ", ";
}
} else if (weekdayRangeStart !== weekdayRangeEnd) {
// exactly 2 consecutive weekdays selected
label += ", ";
} else {
// exactly 1 single day selected
if (maxWeekday !== weekdayRangeEnd) {
label += ", ";
}
}
}
}
return label;
},
},
};

View file

@ -137,3 +137,35 @@ describe("fmtDuration", () => {
config.global.mocks["$i18n"].locale = "de-DE";
});
});
describe("getWeekdaysList", () => {
test("should return the correct weekday-order", () => {
expect(fmt.getWeekdaysList("long")).toEqual([
{ name: "Montag", value: 1 },
{ name: "Dienstag", value: 2 },
{ name: "Mittwoch", value: 3 },
{ name: "Donnerstag", value: 4 },
{ name: "Freitag", value: 5 },
{ name: "Samstag", value: 6 },
{ name: "Sonntag", value: 0 },
]);
});
});
describe("getShortenedWeekdaysLabel", () => {
test("should format single days", () => {
expect(fmt.getShortenedWeekdaysLabel([0])).eq("So");
expect(fmt.getShortenedWeekdaysLabel([0, 2, 4, 6])).eq("Di, Do, Sa, So");
expect(fmt.getShortenedWeekdaysLabel([6])).eq("Sa");
expect(fmt.getShortenedWeekdaysLabel([3, 6])).eq("Mi, Sa");
});
test("should format ranges", () => {
expect(fmt.getShortenedWeekdaysLabel([1, 2])).eq("Mo, Di");
expect(fmt.getShortenedWeekdaysLabel([0, 1, 2, 3, 4, 5, 6])).eq("Mo – So");
expect(fmt.getShortenedWeekdaysLabel([0, 1, 3, 4, 5])).eq("Mo, Mi – Fr, So");
});
test("should format single days and ranges", () => {
expect(fmt.getShortenedWeekdaysLabel([0, 1, 3, 5, 6])).eq("Mo, Mi, Fr – So");
expect(fmt.getShortenedWeekdaysLabel([0, 2, 3, 5, 6])).eq("Di, Mi, Fr – So");
});
});

View file

@ -38,6 +38,7 @@ const (
// effective values
EffectivePriority = "effectivePriority" // effective priority
EffectivePlanId = "effectivePlanId" // effective plan id
EffectivePlanTime = "effectivePlanTime" // effective plan time
EffectivePlanSoc = "effectivePlanSoc" // effective plan soc
EffectiveMinCurrent = "effectiveMinCurrent" // effective min current
@ -67,6 +68,9 @@ const (
PlanProjectedEnd = "planProjectedEnd" // charge plan ends (end of last slot)
PlanOverrun = "planOverrun" // charge plan goal not reachable in time
// repeating plans
RepeatingPlans = "repeatingPlans" // key to access all repeating plans in db
// remote control
RemoteDisabled = "remoteDisabled" // remote disabled
RemoteDisabledSource = "remoteDisabledSource" // remote disabled source

View file

@ -67,6 +67,8 @@ type API interface {
// EffectivePriority returns the effective priority
EffectivePriority() int
// EffectivePlanId returns the effective plan id
EffectivePlanId() int
// EffectivePlanTime returns the effective plan time
EffectivePlanTime() time.Time
// EffectiveMinPower returns the min charging power for the minimum active phases

View file

@ -83,6 +83,20 @@ func (mr *MockAPIMockRecorder) EffectiveMinPower() *gomock.Call {
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "EffectiveMinPower", reflect.TypeOf((*MockAPI)(nil).EffectiveMinPower))
}
// EffectivePlanId mocks base method.
func (m *MockAPI) EffectivePlanId() int {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "EffectivePlanId")
ret0, _ := ret[0].(int)
return ret0
}
// EffectivePlanId indicates an expected call of EffectivePlanId.
func (mr *MockAPIMockRecorder) EffectivePlanId() *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "EffectivePlanId", reflect.TypeOf((*MockAPI)(nil).EffectivePlanId))
}
// EffectivePlanTime mocks base method.
func (m *MockAPI) EffectivePlanTime() time.Time {
m.ctrl.T.Helper()

View file

@ -1,16 +1,19 @@
package core
import (
"sort"
"time"
"github.com/evcc-io/evcc/api"
"github.com/evcc-io/evcc/core/keys"
"github.com/evcc-io/evcc/core/vehicle"
"github.com/evcc-io/evcc/util"
)
// PublishEffectiveValues publishes all effective values
func (lp *Loadpoint) PublishEffectiveValues() {
lp.publish(keys.EffectivePriority, lp.EffectivePriority())
lp.publish(keys.EffectivePlanId, lp.EffectivePlanId())
lp.publish(keys.EffectivePlanTime, lp.EffectivePlanTime())
lp.publish(keys.EffectivePlanSoc, lp.EffectivePlanSoc())
lp.publish(keys.EffectiveMinCurrent, lp.effectiveMinCurrent())
@ -28,24 +31,72 @@ func (lp *Loadpoint) EffectivePriority() int {
return lp.GetPriority()
}
// vehiclePlanSoc returns the next vehicle plan time and soc
func (lp *Loadpoint) vehiclePlanSoc() (time.Time, int) {
// nextVehiclePlan returns the next vehicle plan time, soc and id
func (lp *Loadpoint) nextVehiclePlan() (time.Time, int, int) {
if v := lp.GetVehicle(); v != nil {
return vehicle.Settings(lp.log, v).GetPlanSoc()
type plan struct {
Time time.Time
Soc int
Id int
}
var plans []plan
// static plan
if planTime, soc := vehicle.Settings(lp.log, v).GetPlanSoc(); soc != 0 {
plans = append(plans, plan{Id: 1, Soc: soc, Time: planTime})
}
// repeating plans
for index, rp := range vehicle.Settings(lp.log, v).GetRepeatingPlans() {
if !rp.Active || len(rp.Weekdays) == 0 {
continue
}
time, err := util.GetNextOccurrence(rp.Weekdays, rp.Time, rp.Tz)
if err != nil {
lp.log.DEBUG.Printf("invalid repeating plan: weekdays=%v, time=%s, tz=%s, error=%v", rp.Weekdays, rp.Time, rp.Tz, err)
continue
}
plans = append(plans, plan{Id: index + 2, Soc: rp.Soc, Time: time})
}
// sort plans by time
sort.Slice(plans, func(i, j int) bool {
return plans[i].Time.Before(plans[j].Time)
})
if len(plans) > 0 {
return plans[0].Time, plans[0].Soc, plans[0].Id
}
}
return time.Time{}, 0
return time.Time{}, 0, 0
}
// EffectivePlanSoc returns the soc target for the current plan
func (lp *Loadpoint) EffectivePlanSoc() int {
_, soc := lp.vehiclePlanSoc()
_, soc, _ := lp.nextVehiclePlan()
return soc
}
// EffectivePlanSoc returns the id for the current plan
func (lp *Loadpoint) EffectivePlanId() int {
if lp.socBasedPlanning() {
_, _, id := lp.nextVehiclePlan()
return id
}
if lp.planEnergy > 0 {
return 1
}
// no plan
return 0
}
// EffectivePlanTime returns the effective plan time
func (lp *Loadpoint) EffectivePlanTime() time.Time {
if lp.socBasedPlanning() {
ts, _ := lp.vehiclePlanSoc()
ts, _, _ := lp.nextVehiclePlan()
return ts
}

View file

@ -64,7 +64,7 @@ func (lp *Loadpoint) GetPlanGoal() (float64, bool) {
defer lp.RUnlock()
if lp.socBasedPlanning() {
_, soc := vehicle.Settings(lp.log, lp.GetVehicle()).GetPlanSoc()
_, soc, _ := lp.nextVehiclePlan()
return float64(soc), true
}

View file

@ -18,17 +18,18 @@ type planStruct struct {
}
type vehicleStruct struct {
Title string `json:"title"`
Icon string `json:"icon,omitempty"`
Capacity float64 `json:"capacity,omitempty"`
Phases int `json:"phases,omitempty"`
MinSoc int `json:"minSoc,omitempty"`
LimitSoc int `json:"limitSoc,omitempty"`
MinCurrent float64 `json:"minCurrent,omitempty"`
MaxCurrent float64 `json:"maxCurrent,omitempty"`
Priority int `json:"priority,omitempty"`
Features []string `json:"features,omitempty"`
Plans []planStruct `json:"plans,omitempty"`
Title string `json:"title"`
Icon string `json:"icon,omitempty"`
Capacity float64 `json:"capacity,omitempty"`
Phases int `json:"phases,omitempty"`
MinSoc int `json:"minSoc,omitempty"`
LimitSoc int `json:"limitSoc,omitempty"`
MinCurrent float64 `json:"minCurrent,omitempty"`
MaxCurrent float64 `json:"maxCurrent,omitempty"`
Priority int `json:"priority,omitempty"`
Features []string `json:"features,omitempty"`
Plan *planStruct `json:"plan,omitempty"`
RepeatingPlans []api.RepeatingPlanStruct `json:"repeatingPlans"`
}
// publishVehicles returns a list of vehicle titles
@ -37,28 +38,28 @@ func (site *Site) publishVehicles() {
res := make(map[string]vehicleStruct, len(vv))
for _, v := range vv {
var plans []planStruct
var plan *planStruct
// TODO: add support for multiple plans
if time, soc := v.GetPlanSoc(); !time.IsZero() {
plans = append(plans, planStruct{Soc: soc, Time: time})
plan = &planStruct{Soc: soc, Time: time}
}
instance := v.Instance()
ac := instance.OnIdentified()
res[v.Name()] = vehicleStruct{
Title: instance.Title(),
Icon: instance.Icon(),
Capacity: instance.Capacity(),
Phases: instance.Phases(),
MinSoc: v.GetMinSoc(),
LimitSoc: v.GetLimitSoc(),
MinCurrent: ac.MinCurrent,
MaxCurrent: ac.MaxCurrent,
Priority: ac.Priority,
Features: lo.Map(instance.Features(), func(f api.Feature, _ int) string { return f.String() }),
Plans: plans,
Title: instance.Title(),
Icon: instance.Icon(),
Capacity: instance.Capacity(),
Phases: instance.Phases(),
MinSoc: v.GetMinSoc(),
LimitSoc: v.GetLimitSoc(),
MinCurrent: ac.MinCurrent,
MaxCurrent: ac.MaxCurrent,
Priority: ac.Priority,
Features: lo.Map(instance.Features(), func(f api.Feature, _ int) string { return f.String() }),
Plan: plan,
RepeatingPlans: v.GetRepeatingPlans(),
}
if lp := site.coordinator.Owner(instance); lp != nil {

View file

@ -103,3 +103,38 @@ func (v *adapter) SetPlanSoc(ts time.Time, soc int) error {
return nil
}
func (v *adapter) SetRepeatingPlans(plans []api.RepeatingPlanStruct) error {
for _, plan := range plans {
for _, day := range plan.Weekdays {
if day < 0 || day > 6 {
return fmt.Errorf("weekday out of range: %v", day)
}
}
if _, err := time.LoadLocation(plan.Tz); err != nil {
return fmt.Errorf("invalid timezone: %v", err)
}
if _, err := time.Parse("15:04", plan.Time); err != nil {
return fmt.Errorf("invalid time: %v", err)
}
}
v.log.DEBUG.Printf("update repeating plans for %s to: %v", v.name, plans)
settings.SetJson(v.key()+keys.RepeatingPlans, plans)
v.publish()
return nil
}
func (v *adapter) GetRepeatingPlans() []api.RepeatingPlanStruct {
var plans []api.RepeatingPlanStruct
err := settings.Json(v.key()+keys.RepeatingPlans, &plans)
if err == nil {
return plans
}
return []api.RepeatingPlanStruct{}
}

View file

@ -43,6 +43,11 @@ type API interface {
// SetPlanSoc sets the charge plan time and soc
SetPlanSoc(time.Time, int) error
// GetRepeatingPlans returns every repeating plan
GetRepeatingPlans() []api.RepeatingPlanStruct
// SetRepeatingPlans stores every repeating plan
SetRepeatingPlans([]api.RepeatingPlanStruct) error
// // GetMinCurrent returns the min charging current
// GetMinCurrent() float64
// // SetMinCurrent sets the min charging current

View file

@ -47,3 +47,12 @@ func (v *dummy) GetPlanSoc() (time.Time, int) {
func (v *dummy) SetPlanSoc(ts time.Time, soc int) error {
return nil
}
// SetRepeatingPlans stores every repeating plan
func (v *dummy) SetRepeatingPlans(plans []api.RepeatingPlanStruct) error {
return nil
}
func (v *dummy) GetRepeatingPlans() []api.RepeatingPlanStruct {
return []api.RepeatingPlanStruct{}
}

View file

@ -84,6 +84,20 @@ func (mr *MockAPIMockRecorder) GetPlanSoc() *gomock.Call {
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetPlanSoc", reflect.TypeOf((*MockAPI)(nil).GetPlanSoc))
}
// GetRepeatingPlans mocks base method.
func (m *MockAPI) GetRepeatingPlans() []api.RepeatingPlanStruct {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "GetRepeatingPlans")
ret0, _ := ret[0].([]api.RepeatingPlanStruct)
return ret0
}
// GetRepeatingPlans indicates an expected call of GetRepeatingPlans.
func (mr *MockAPIMockRecorder) GetRepeatingPlans() *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetRepeatingPlans", reflect.TypeOf((*MockAPI)(nil).GetRepeatingPlans))
}
// Instance mocks base method.
func (m *MockAPI) Instance() api.Vehicle {
m.ctrl.T.Helper()
@ -149,3 +163,17 @@ func (mr *MockAPIMockRecorder) SetPlanSoc(arg0, arg1 any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SetPlanSoc", reflect.TypeOf((*MockAPI)(nil).SetPlanSoc), arg0, arg1)
}
// SetRepeatingPlans mocks base method.
func (m *MockAPI) SetRepeatingPlans(arg0 []api.RepeatingPlanStruct) error {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "SetRepeatingPlans", arg0)
ret0, _ := ret[0].(error)
return ret0
}
// SetRepeatingPlans indicates an expected call of SetRepeatingPlans.
func (mr *MockAPIMockRecorder) SetRepeatingPlans(arg0 any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SetRepeatingPlans", reflect.TypeOf((*MockAPI)(nil).SetRepeatingPlans), arg0)
}

View file

@ -384,19 +384,25 @@ vehicles = "Parkplatz"
[main.chargingPlan]
active = "Aktiv"
addRepeatingPlan = "Wiederholenden Plan hinzufügen"
arrivalTab = "Ankunft"
day = "Tag"
departureTab = "Abfahrt"
goal = "Ladeziel"
modalTitle = "Ladeplanung"
none = "keiner"
planNumber = "Plan {number}"
remove = "Entfernen"
repeating = "wiederholend"
repeatingPlans = "Wiederholende Pläne"
selectAll = "Alle wählen"
time = "Zeit"
title = "Plan"
titleMinSoc = "Min. Ladung"
titleTargetCharge = "Abfahrt"
unsavedChanges = "Ungespeicherte Änderungen vorhanden. Jetzt anwenden?"
update = "Anwenden"
weekdays = "Wochentage"
[main.energyflow]
battery = "Batterie"
@ -490,6 +496,7 @@ currentPlan = "Aktiver Plan"
descriptionEnergy = "Bis wann sollen {targetEnergy} ins Fahrzeug geladen sein?"
descriptionSoc = "Wann soll das Fahrzeug auf {targetSoc} geladen sein?"
inactiveLabel = "Zielzeit"
nextPlan = "Nächster Plan"
notReachableInTime = "Zielzeit wird {overrun} später erreicht."
onlyInPvMode = "Ladeplanung ist nur im PV-Modus aktiv."
planDuration = "Ladedauer"

View file

@ -383,19 +383,25 @@ vehicles = "Parking"
[main.chargingPlan]
active = "Active"
addRepeatingPlan = "Add repeating plan"
arrivalTab = "Arrival"
day = "Day"
departureTab = "Departure"
goal = "Charging goal"
modalTitle = "Charging Plan"
none = "none"
planNumber = "Plan {number}"
remove = "Remove"
repeating = "repeating"
repeatingPlans = "Repeating plans"
selectAll = "Select all"
time = "Time"
title = "Plan"
titleMinSoc = "Min charge"
titleTargetCharge = "Departure"
unsavedChanges = "There are unsaved changes. Apply now?"
update = "Apply"
weekdays = "Weekdays"
[main.energyflow]
battery = "Battery"
@ -489,6 +495,7 @@ currentPlan = "Active plan"
descriptionEnergy = "Until when should {targetEnergy} be loaded into the vehicle?"
descriptionSoc = "When should the vehicle be charged to {targetSoc}?"
inactiveLabel = "Target time"
nextPlan = "Next plan"
notReachableInTime = "Goal will be reached {overrun} later."
onlyInPvMode = "Charging plan only works in solar mode."
planDuration = "Charging time"

View file

@ -138,10 +138,11 @@ func (s *HTTPd) RegisterSiteHandlers(site site.API, valueChan chan<- util.Param)
// vehicle api
vehicles := map[string]route{
"minsoc": {"POST", "/vehicles/{name:[a-zA-Z0-9_.:-]+}/minsoc/{value:[0-9]+}", minSocHandler(site)},
"limitsoc": {"POST", "/vehicles/{name:[a-zA-Z0-9_.:-]+}/limitsoc/{value:[0-9]+}", limitSocHandler(site)},
"plan": {"POST", "/vehicles/{name:[a-zA-Z0-9_.:-]+}/plan/soc/{value:[0-9]+}/{time:[0-9TZ:.+-]+}", planSocHandler(site)},
"plan2": {"DELETE", "/vehicles/{name:[a-zA-Z0-9_.:-]+}/plan/soc", planSocRemoveHandler(site)},
"minsoc": {"POST", "/vehicles/{name:[a-zA-Z0-9_.:-]+}/minsoc/{value:[0-9]+}", minSocHandler(site)},
"limitsoc": {"POST", "/vehicles/{name:[a-zA-Z0-9_.:-]+}/limitsoc/{value:[0-9]+}", limitSocHandler(site)},
"plan": {"POST", "/vehicles/{name:[a-zA-Z0-9_.:-]+}/plan/soc/{value:[0-9]+}/{time:[0-9TZ:.+-]+}", planSocHandler(site)},
"plan2": {"DELETE", "/vehicles/{name:[a-zA-Z0-9_.:-]+}/plan/soc", planSocRemoveHandler(site)},
"repeatingPlans": {"POST", "/vehicles/{name:[a-zA-Z0-9_.:-]+}/plan/repeating", addRepeatingPlansHandler(site)},
// config ui
// "mode": {"POST", "/mode/{value:[a-z]+}", chargeModeHandler(v)},
@ -159,28 +160,29 @@ func (s *HTTPd) RegisterSiteHandlers(site site.API, valueChan chan<- util.Param)
api := api.PathPrefix(fmt.Sprintf("/loadpoints/%d", id+1)).Subrouter()
routes := map[string]route{
"mode": {"POST", "/mode/{value:[a-z]+}", handler(eapi.ChargeModeString, pass(lp.SetMode), lp.GetMode)},
"limitsoc": {"POST", "/limitsoc/{value:[0-9]+}", intHandler(pass(lp.SetLimitSoc), lp.GetLimitSoc)},
"limitenergy": {"POST", "/limitenergy/{value:[0-9.]+}", floatHandler(pass(lp.SetLimitEnergy), lp.GetLimitEnergy)},
"mincurrent": {"POST", "/mincurrent/{value:[0-9.]+}", floatHandler(lp.SetMinCurrent, lp.GetMinCurrent)},
"maxcurrent": {"POST", "/maxcurrent/{value:[0-9.]+}", floatHandler(lp.SetMaxCurrent, lp.GetMaxCurrent)},
"phases": {"POST", "/phases/{value:[0-9]+}", intHandler(lp.SetPhases, lp.GetPhases)},
"plan": {"GET", "/plan", planHandler(lp)},
"planpreview": {"GET", "/plan/preview/{type:(?:soc|energy)}/{value:[0-9.]+}/{time:[0-9TZ:.+-]+}", planPreviewHandler(lp)},
"planenergy": {"POST", "/plan/energy/{value:[0-9.]+}/{time:[0-9TZ:.+-]+}", planEnergyHandler(lp)},
"planenergy2": {"DELETE", "/plan/energy", planRemoveHandler(lp)},
"vehicle": {"POST", "/vehicle/{name:[a-zA-Z0-9_.:-]+}", vehicleSelectHandler(site, lp)},
"vehicle2": {"DELETE", "/vehicle", vehicleRemoveHandler(lp)},
"vehicleDetect": {"PATCH", "/vehicle", vehicleDetectHandler(lp)},
"remotedemand": {"POST", "/remotedemand/{demand:[a-z]+}/{source:[0-9a-zA-Z_-]+}", remoteDemandHandler(lp)},
"enableThreshold": {"POST", "/enable/threshold/{value:-?[0-9.]+}", floatHandler(pass(lp.SetEnableThreshold), lp.GetEnableThreshold)},
"enableDelay": {"POST", "/enable/delay/{value:[0-9]+}", durationHandler(pass(lp.SetEnableDelay), lp.GetEnableDelay)},
"disableThreshold": {"POST", "/disable/threshold/{value:-?[0-9.]+}", floatHandler(pass(lp.SetDisableThreshold), lp.GetDisableThreshold)},
"disableDelay": {"POST", "/disable/delay/{value:[0-9]+}", durationHandler(pass(lp.SetDisableDelay), lp.GetDisableDelay)},
"smartCost": {"POST", "/smartcostlimit/{value:-?[0-9.]+}", floatPtrHandler(pass(lp.SetSmartCostLimit), lp.GetSmartCostLimit)},
"smartCostDelete": {"DELETE", "/smartcostlimit", floatPtrHandler(pass(lp.SetSmartCostLimit), lp.GetSmartCostLimit)},
"priority": {"POST", "/priority/{value:[0-9]+}", intHandler(pass(lp.SetPriority), lp.GetPriority)},
"batteryBoost": {"POST", "/batteryboost/{value:[01truefalse]}", boolHandler(lp.SetBatteryBoost, lp.GetBatteryBoost)},
"mode": {"POST", "/mode/{value:[a-z]+}", handler(eapi.ChargeModeString, pass(lp.SetMode), lp.GetMode)},
"limitsoc": {"POST", "/limitsoc/{value:[0-9]+}", intHandler(pass(lp.SetLimitSoc), lp.GetLimitSoc)},
"limitenergy": {"POST", "/limitenergy/{value:[0-9.]+}", floatHandler(pass(lp.SetLimitEnergy), lp.GetLimitEnergy)},
"mincurrent": {"POST", "/mincurrent/{value:[0-9.]+}", floatHandler(lp.SetMinCurrent, lp.GetMinCurrent)},
"maxcurrent": {"POST", "/maxcurrent/{value:[0-9.]+}", floatHandler(lp.SetMaxCurrent, lp.GetMaxCurrent)},
"phases": {"POST", "/phases/{value:[0-9]+}", intHandler(lp.SetPhases, lp.GetPhases)},
"plan": {"GET", "/plan", planHandler(lp)},
"staticPlanPreview": {"GET", "/plan/static/preview/{type:(?:soc|energy)}/{value:[0-9.]+}/{time:[0-9TZ:.+-]+}", staticPlanPreviewHandler(lp)},
"repeatingPlanPreview": {"GET", "/plan/repeating/preview/{soc:[0-9]+}/{weekdays:[0-6,]+}/{time:[0-2][0-9]:[0-5][0-9]}/{tz:[a-zA-Z0-9_./:-]+}", repeatingPlanPreviewHandler(lp)},
"planenergy": {"POST", "/plan/energy/{value:[0-9.]+}/{time:[0-9TZ:.+-]+}", planEnergyHandler(lp)},
"planenergy2": {"DELETE", "/plan/energy", planRemoveHandler(lp)},
"vehicle": {"POST", "/vehicle/{name:[a-zA-Z0-9_.:-]+}", vehicleSelectHandler(site, lp)},
"vehicle2": {"DELETE", "/vehicle", vehicleRemoveHandler(lp)},
"vehicleDetect": {"PATCH", "/vehicle", vehicleDetectHandler(lp)},
"remotedemand": {"POST", "/remotedemand/{demand:[a-z]+}/{source:[0-9a-zA-Z_-]+}", remoteDemandHandler(lp)},
"enableThreshold": {"POST", "/enable/threshold/{value:-?[0-9.]+}", floatHandler(pass(lp.SetEnableThreshold), lp.GetEnableThreshold)},
"enableDelay": {"POST", "/enable/delay/{value:[0-9]+}", durationHandler(pass(lp.SetEnableDelay), lp.GetEnableDelay)},
"disableThreshold": {"POST", "/disable/threshold/{value:-?[0-9.]+}", floatHandler(pass(lp.SetDisableThreshold), lp.GetDisableThreshold)},
"disableDelay": {"POST", "/disable/delay/{value:[0-9]+}", durationHandler(pass(lp.SetDisableDelay), lp.GetDisableDelay)},
"smartCost": {"POST", "/smartcostlimit/{value:-?[0-9.]+}", floatPtrHandler(pass(lp.SetSmartCostLimit), lp.GetSmartCostLimit)},
"smartCostDelete": {"DELETE", "/smartcostlimit", floatPtrHandler(pass(lp.SetSmartCostLimit), lp.GetSmartCostLimit)},
"priority": {"POST", "/priority/{value:[0-9]+}", intHandler(pass(lp.SetPriority), lp.GetPriority)},
"batteryBoost": {"POST", "/batteryboost/{value:[01truefalse]}", boolHandler(lp.SetBatteryBoost, lp.GetBatteryBoost)},
}
for _, r := range routes {

View file

@ -5,11 +5,13 @@ import (
"fmt"
"net/http"
"strconv"
"strings"
"time"
"github.com/evcc-io/evcc/api"
"github.com/evcc-io/evcc/core/loadpoint"
"github.com/evcc-io/evcc/core/site"
"github.com/evcc-io/evcc/util"
"github.com/gorilla/mux"
)
@ -44,6 +46,7 @@ func planHandler(lp loadpoint.API) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
maxPower := lp.EffectiveMaxPower()
planTime := lp.EffectivePlanTime()
id := lp.EffectivePlanId()
goal, _ := lp.GetPlanGoal()
requiredDuration := lp.GetPlanRequiredDuration(goal, maxPower)
@ -54,11 +57,13 @@ func planHandler(lp loadpoint.API) http.HandlerFunc {
}
res := struct {
PlanId int `json:"planId"`
PlanTime time.Time `json:"planTime"`
Duration int64 `json:"duration"`
Plan api.Rates `json:"plan"`
Power float64 `json:"power"`
}{
PlanId: id,
PlanTime: planTime,
Duration: int64(requiredDuration.Seconds()),
Plan: plan,
@ -69,8 +74,8 @@ func planHandler(lp loadpoint.API) http.HandlerFunc {
}
}
// planPreviewHandler returns a plan preview for given parameters
func planPreviewHandler(lp loadpoint.API) http.HandlerFunc {
// staticPlanPreviewHandler returns a plan preview for given parameters
func staticPlanPreviewHandler(lp loadpoint.API) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
@ -126,6 +131,59 @@ func planPreviewHandler(lp loadpoint.API) http.HandlerFunc {
}
}
func repeatingPlanPreviewHandler(lp loadpoint.API) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
hourMinute := vars["time"]
tz := vars["tz"]
var weekdays []int
for _, weekdayStr := range strings.Split(vars["weekdays"], ",") {
weekday, err := strconv.Atoi(weekdayStr)
if err != nil {
jsonError(w, http.StatusBadRequest, fmt.Errorf("invalid weekdays format"))
return
}
weekdays = append(weekdays, weekday)
}
soc, err := strconv.ParseFloat(vars["soc"], 64)
if err != nil {
jsonError(w, http.StatusBadRequest, err)
return
}
planTime, err := util.GetNextOccurrence(weekdays, hourMinute, tz)
if err != nil {
jsonError(w, http.StatusBadRequest, err)
return
}
maxPower := lp.EffectiveMaxPower()
requiredDuration := lp.GetPlanRequiredDuration(soc, maxPower)
plan, err := lp.GetPlan(planTime, requiredDuration)
if err != nil {
w.WriteHeader(http.StatusBadRequest)
return
}
res := struct {
PlanTime time.Time `json:"planTime"`
Duration int64 `json:"duration"`
Plan api.Rates `json:"plan"`
Power float64 `json:"power"`
}{
PlanTime: planTime,
Duration: int64(requiredDuration.Seconds()),
Plan: plan,
Power: maxPower,
}
jsonResult(w, res)
}
}
// planEnergyHandler updates plan energy and time
func planEnergyHandler(lp loadpoint.API) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {

View file

@ -1,10 +1,12 @@
package server
import (
"encoding/json"
"net/http"
"strconv"
"time"
"github.com/evcc-io/evcc/api"
"github.com/evcc-io/evcc/core/site"
"github.com/gorilla/mux"
)
@ -109,6 +111,36 @@ func planSocHandler(site site.API) http.HandlerFunc {
}
}
// addRepeatingPlansHandler handles any information regarding weekday, hour, minute, soc and isActive
func addRepeatingPlansHandler(site site.API) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
v, err := site.Vehicles().ByName(vars["name"])
if err != nil {
jsonError(w, http.StatusBadRequest, err)
return
}
var plansWrapper struct {
RepeatingPlans []api.RepeatingPlanStruct `json:"plans"`
}
err = json.NewDecoder(r.Body).Decode(&plansWrapper)
if err != nil {
jsonError(w, http.StatusBadRequest, err)
return
}
if err := v.SetRepeatingPlans(plansWrapper.RepeatingPlans); err != nil {
jsonError(w, http.StatusBadRequest, err)
return
}
jsonResult(w, plansWrapper)
}
}
// planSocRemoveHandler removes plan soc and time
func planSocRemoveHandler(site site.API) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {

View file

@ -1,5 +1,5 @@
import { test, expect } from "@playwright/test";
import { start, stop, baseUrl } from "./evcc";
import { start, stop, baseUrl, restart } from "./evcc";
test.use({ baseURL: baseUrl() });
test.describe.configure({ mode: "parallel" });
@ -14,25 +14,41 @@ test.afterEach(async () => {
await stop();
});
function getWeekday(offset = 1) {
const date = new Date();
date.setDate(date.getDate() + offset);
return date.toLocaleDateString("en-US", { weekday: "long" });
}
async function setAndVerifyPlan(page, lp, { soc, energy }) {
await lp.getByTestId("charging-plan").getByRole("button", { name: "none" }).click();
await lp.getByTestId("charging-plan-button").click();
if (soc) {
await page.getByTestId("plan-soc").selectOption(soc);
await page.getByTestId("static-plan-soc").selectOption(soc);
}
if (energy) {
// select "25 kWh (+50%)" by providing "25 kWh" as option text
const optionText = await page
.getByTestId("plan-energy")
.getByTestId("static-plan-energy")
.locator("option", { hasText: energy })
.textContent();
await page.getByTestId("plan-energy").selectOption(optionText);
await page.getByTestId("static-plan-energy").selectOption(optionText);
}
await page.getByTestId("plan-active").click();
await page.getByTestId("static-plan-active").click();
await page.getByRole("button", { name: "Close" }).click();
await expect(lp.getByTestId("charging-plan")).toContainText(soc || energy);
}
async function verifyRepeatingPlanAvailable(page, lp, expected) {
await lp.getByTestId("charging-plan-button").click();
if (expected) {
await expect(page.getByTestId("repeating-plan-add")).toBeVisible();
} else {
await expect(page.getByTestId("repeating-plan-add")).not.toBeVisible();
}
await page.getByRole("button", { name: "Close" }).click();
}
test.describe("basic functionality", async () => {
test("vehicle with soc and capacity, set and restart", async ({ page }) => {
await page.goto("/");
@ -52,10 +68,10 @@ test.describe("basic functionality", async () => {
await lp1.getByRole("button", { name: "Solar", exact: true }).click();
await lp1.getByTestId("charging-plan").getByRole("button", { name: "none" }).click();
await page.getByTestId("plan-day").selectOption({ index: 1 });
await page.getByTestId("plan-time").fill("09:30");
await page.getByTestId("plan-soc").selectOption("80%");
await page.getByTestId("plan-active").click();
await page.getByTestId("static-plan-day").selectOption({ index: 1 });
await page.getByTestId("static-plan-time").fill("09:30");
await page.getByTestId("static-plan-soc").selectOption("80%");
await page.getByTestId("static-plan-active").click();
await page.getByRole("button", { name: "Close" }).click();
await expect(lp1.getByTestId("plan-marker")).toBeVisible();
@ -70,13 +86,13 @@ test.describe("basic functionality", async () => {
"tomorrow 9:30 AM80%"
);
await lp1.getByTestId("charging-plan").getByRole("button").click();
await expect(page.getByTestId("plan-soc")).toHaveValue("80");
await expect(page.getByTestId("static-plan-soc")).toHaveValue("80");
});
});
test.describe("vehicle variations", async () => {
test.describe("guest vehicle", async () => {
test("kWh based plan and limit", async ({ page }) => {
test("kWh based plan and limit, no repeats", async ({ page }) => {
await page.goto("/");
const lp1 = await page.getByTestId("loadpoint").first();
@ -88,11 +104,14 @@ test.describe("vehicle variations", async () => {
// kWh based plan
await setAndVerifyPlan(page, lp1, { energy: "25 kWh" });
// no repeating plans option
await verifyRepeatingPlanAvailable(page, lp1, false);
});
});
test.describe("vehicle no soc no capacity", async () => {
test("kWh based plan and limit", async ({ page }) => {
test("kWh based plan and limit, no repeats", async ({ page }) => {
await page.goto("/");
const lp1 = await page.getByTestId("loadpoint").first();
@ -108,11 +127,14 @@ test.describe("vehicle variations", async () => {
// kWh based plan
await setAndVerifyPlan(page, lp1, { energy: "25 kWh" });
// no repeating plans option
await verifyRepeatingPlanAvailable(page, lp1, false);
});
});
test.describe("vehicle no soc with capacity", async () => {
test("kWh based plan and limit", async ({ page }) => {
test("kWh based plan and limit, no repeats", async ({ page }) => {
await page.goto("/");
const lp1 = await page.getByTestId("loadpoint").first();
@ -128,11 +150,14 @@ test.describe("vehicle variations", async () => {
// kWh based plan
await setAndVerifyPlan(page, lp1, { energy: "25 kWh" });
// no repeating plans option
await verifyRepeatingPlanAvailable(page, lp1, false);
});
});
test.describe("vehicle with soc no capacity", async () => {
test("kWh based plan and soc based limit", async ({ page }) => {
test("kWh based plan and soc based limit, no repeats", async ({ page }) => {
await page.goto("/");
const lp1 = await page.getByTestId("loadpoint").first();
@ -148,11 +173,14 @@ test.describe("vehicle variations", async () => {
// soc based plan
await setAndVerifyPlan(page, lp1, { energy: "50 kWh" });
// no repeating plans option
await verifyRepeatingPlanAvailable(page, lp1, false);
});
});
test.describe("vehicle with soc with capacity", async () => {
test("soc based plan and limit", async ({ page }) => {
test("soc based plan and limit, with repeats", async ({ page }) => {
await page.goto("/");
const lp1 = await page.getByTestId("loadpoint").first();
@ -168,11 +196,14 @@ test.describe("vehicle variations", async () => {
// soc based plan
await setAndVerifyPlan(page, lp1, { soc: "60%" });
// repeating plans option
await verifyRepeatingPlanAvailable(page, lp1, true);
});
});
test.describe("loadpoint with soc, guest vehicle", async () => {
test("kWh based plan and soc based limit", async ({ page }) => {
test("kWh based plan and soc based limit, no repeats", async ({ page }) => {
await page.goto("/");
const lp2 = await page.getByTestId("loadpoint").last();
@ -185,11 +216,14 @@ test.describe("vehicle variations", async () => {
// soc based plan
await setAndVerifyPlan(page, lp2, { energy: "50 kWh" });
// no repeating plans option
await verifyRepeatingPlanAvailable(page, lp2, false);
});
});
test.describe("loadpoint with soc, vehicle with capacity", async () => {
test("soc based plan and limit", async ({ page }) => {
test("soc based plan and limit, with repeats", async ({ page }) => {
await page.goto("/");
const lp2 = await page.getByTestId("loadpoint").last();
@ -205,6 +239,9 @@ test.describe("vehicle variations", async () => {
// soc based plan
await setAndVerifyPlan(page, lp2, { soc: "60%" });
// repeating plans option
await verifyRepeatingPlanAvailable(page, lp2, true);
});
});
});
@ -214,12 +251,12 @@ test.describe("preview", async () => {
{
szenario: "kWh based plan",
vehicle: "Vehicle no SoC with Capacity",
goalId: "plan-energy",
goalId: "static-plan-energy",
},
{
szenario: "soc based plan",
vehicle: "Vehicle with SoC with Capacity",
goalId: "plan-soc",
goalId: "static-plan-soc",
},
];
@ -235,29 +272,29 @@ test.describe("preview", async () => {
await lp1.getByTestId("charging-plan").getByRole("button", { name: "none" }).click();
// initial set -> preview plan
await page.getByTestId("plan-day").selectOption({ index: 1 });
await page.getByTestId("plan-time").fill("09:30");
await page.getByTestId("static-plan-day").selectOption({ index: 1 });
await page.getByTestId("static-plan-time").fill("09:30");
await page.getByTestId(c.goalId).selectOption("80");
await expect(page.getByTestId("plan-preview-title")).toHaveText("Preview plan");
// activate -> active plan
await page.getByTestId("plan-active").click();
await page.getByTestId("static-plan-active").click();
await expect(page.getByTestId("plan-preview-title")).toHaveText("Active plan");
// change -> preview plan
await page.getByTestId(c.goalId).selectOption("90");
await expect(page.getByTestId("plan-preview-title")).toHaveText("Preview plan");
await expect(page.getByTestId("plan-preview-title")).toHaveText("Active plan");
// apply -> active plan
await expect(page.getByTestId("plan-apply")).toBeVisible();
await page.getByTestId("plan-apply").click();
await expect(page.getByTestId("static-plan-apply")).toBeVisible();
await page.getByTestId("static-plan-apply").click();
await expect(page.getByTestId("plan-preview-title")).toHaveText("Active plan");
// deactivate -> stay in preview
await page.getByTestId("plan-time").fill("23:30");
await expect(page.getByTestId("plan-preview-title")).toHaveText("Preview plan");
await expect(page.getByTestId("plan-apply")).toBeVisible();
await page.getByTestId("plan-active").click();
await page.getByTestId("static-plan-time").fill("23:30");
await expect(page.getByTestId("plan-preview-title")).toHaveText("Active plan");
await expect(page.getByTestId("static-plan-apply")).toBeVisible();
// deactivate
await page.getByTestId("static-plan-active").click();
await expect(page.getByTestId("plan-preview-title")).toHaveText("Preview plan");
});
});
@ -277,7 +314,7 @@ test.describe("warnings", async () => {
await lp1.getByTestId("charging-plan").getByRole("button", { name: "none" }).click();
await page.getByTestId("plan-active").click();
await page.getByTestId("static-plan-active").click();
// match this text but with fuzzy date "getByText('Goal will be reached 52:10 h')"
await expect(page.getByTestId("plan-warnings")).toHaveText(/Goal will be reached .* later/);
@ -297,12 +334,258 @@ test.describe("warnings", async () => {
await expect(page.getByTestId("plan-entry-warnings")).not.toBeVisible();
await page.getByTestId("plan-day").selectOption({ index: 0 });
await page.getByTestId("plan-time").fill("00:01");
await page.getByTestId("static-plan-day").selectOption({ index: 0 });
await page.getByTestId("static-plan-time").fill("00:01");
await expect(page.getByTestId("plan-entry-warnings")).toContainText(
"Pick a time in the future, Marty."
);
await page.getByTestId("plan-time").fill("00:01");
await page.getByTestId("static-plan-time").fill("00:01");
});
});
test.describe("repeating", async () => {
test("add and remove plans", async ({ page }) => {
await page.goto("/");
const lp1 = await page.getByTestId("loadpoint").first();
await lp1
.getByTestId("change-vehicle")
.locator("select")
.selectOption("Vehicle with SoC with Capacity");
await lp1.getByTestId("charging-plan").getByRole("button", { name: "none" }).click();
const modal = await page.getByTestId("charging-plan-modal");
// one static plan, no number
await expect(modal.getByTestId("plan-entry")).toHaveCount(1);
await expect(modal.getByTestId("plan-entry").first()).not.toContainText("#1");
// add plan
await modal.getByRole("button", { name: "Add repeating plan" }).click();
await expect(modal.getByTestId("plan-entry")).toHaveCount(2);
await expect(modal.getByTestId("plan-entry").first()).toContainText("#1");
await expect(modal.getByTestId("plan-entry").last()).toContainText("#2");
// remove plan
await modal.getByTestId("plan-entry").last().getByRole("button", { name: "Remove" }).click();
await expect(modal.getByTestId("plan-entry")).toHaveCount(1);
await expect(modal.getByTestId("plan-entry").first()).not.toContainText("#1");
});
test("preview static and repeating plan", async ({ page }) => {
await page.goto("/");
const lp1 = await page.getByTestId("loadpoint").first();
await lp1
.getByTestId("change-vehicle")
.locator("select")
.selectOption("Vehicle with SoC with Capacity");
await lp1.getByTestId("charging-plan").getByRole("button", { name: "none" }).click();
const modal = await page.getByTestId("charging-plan-modal");
// one static plan
await expect(modal.getByTestId("plan-entry")).toHaveCount(1);
await modal.getByTestId("static-plan-day").selectOption({ index: 1 }); // tomorrow
await modal.getByTestId("static-plan-time").fill("09:00");
await expect(modal.getByTestId("plan-preview-title")).toHaveText("Preview plan");
await expect(modal.getByTestId("target-text")).toContainText("9:00 AM");
// add repeating plan
await modal.getByRole("button", { name: "Add repeating plan" }).click();
await modal.getByTestId("repeating-plan-weekdays").click();
await modal.getByRole("checkbox", { name: "Select all" }).check();
await modal.getByTestId("repeating-plan-time").fill("11:11");
// switch between previews
await modal
.getByTestId("plan-preview-title")
.getByRole("combobox")
.selectOption("Preview plan #2");
await expect(modal.getByTestId("target-text")).toContainText("11:11 AM");
await modal
.getByTestId("plan-preview-title")
.getByRole("combobox")
.selectOption("Preview plan #1");
await expect(modal.getByTestId("target-text")).toContainText("9:00 AM");
// activate #1
await modal.getByTestId("static-plan-active").click();
await expect(modal.getByTestId("plan-preview-title")).toHaveText("Next plan #1");
await expect(modal.getByTestId("target-text")).toContainText("9:00 AM");
// activate #2
await modal.getByTestId("static-plan-active").click();
await modal.getByTestId("repeating-plan-active").click();
await expect(modal.getByTestId("plan-preview-title")).toHaveText("Next plan #2");
await expect(modal.getByTestId("target-text")).toContainText("11:11 AM");
// back to preview if no active plan
await modal.getByTestId("repeating-plan-active").click();
await expect(modal.getByTestId("plan-preview-title").locator("option:checked")).toHaveText(
"Preview plan #1"
);
await expect(modal.getByTestId("target-text")).toContainText("9:00 AM");
});
test("weekday selection", async ({ page }) => {
await page.goto("/");
const lp1 = await page.getByTestId("loadpoint").first();
await lp1
.getByTestId("change-vehicle")
.locator("select")
.selectOption("Vehicle with SoC with Capacity");
await lp1.getByTestId("charging-plan").getByRole("button", { name: "none" }).click();
const modal = await page.getByTestId("charging-plan-modal");
await modal.getByRole("button", { name: "Add repeating plan" }).click();
// weekday select should have value "Mo-Fr"
await expect(modal.getByTestId("repeating-plan-weekdays").getByRole("button")).toHaveText(
"Mon – Fri"
);
// select all weekdays
await modal.getByTestId("repeating-plan-weekdays").click();
await modal.getByRole("checkbox", { name: "Select all" }).click();
await expect(modal.getByTestId("repeating-plan-weekdays").getByRole("button")).toHaveText(
"Mon – Sun"
);
// select none
await modal.getByRole("checkbox", { name: "Select all" }).click();
await expect(modal.getByTestId("repeating-plan-weekdays").getByRole("button")).toHaveText("–");
// select specific weekdays
await modal.getByRole("checkbox", { name: "Thursday" }).check();
await expect(modal.getByTestId("repeating-plan-weekdays").getByRole("button")).toHaveText(
"Thu"
);
await modal.getByTestId("repeating-plan-weekdays").click(); // close
// activate
await modal.getByTestId("repeating-plan-time").fill("02:22");
await modal.getByTestId("repeating-plan-active").click();
// specific weekday and time
await expect(modal.getByTestId("plan-preview-title")).toHaveText("Next plan #2");
await expect(modal.getByTestId("target-text")).toContainText("Thu 2:22 AM");
});
test("next plan", async ({ page }) => {
await page.goto("/");
const yesterday = getWeekday(-1);
const tomorrow = getWeekday(1);
const lp1 = await page.getByTestId("loadpoint").first();
await lp1
.getByTestId("change-vehicle")
.locator("select")
.selectOption("Vehicle with SoC with Capacity");
await lp1.getByTestId("charging-plan").getByRole("button", { name: "none" }).click();
const modal = await page.getByTestId("charging-plan-modal");
// configure static plan for tomorrow
const plan1 = modal.getByTestId("plan-entry").nth(0);
await plan1.getByTestId("static-plan-day").selectOption({ index: 1 });
await plan1.getByTestId("static-plan-time").fill("09:30");
await plan1.getByTestId("static-plan-active").click();
// add repeating plan for tomorrow
await modal.getByRole("button", { name: "Add repeating plan" }).click();
const plan2 = modal.getByTestId("plan-entry").nth(1);
const days2 = plan2.getByTestId("repeating-plan-weekdays");
await days2.click();
await days2.getByRole("checkbox", { name: "Select all" }).click();
await days2.getByRole("checkbox", { name: "Select all" }).click();
await days2.getByRole("checkbox", { name: tomorrow }).check();
await days2.click(); // close
await plan2.getByTestId("repeating-plan-time").fill("09:20");
await plan2.getByTestId("repeating-plan-active").click();
// add repeating plan for every day
await modal.getByRole("button", { name: "Add repeating plan" }).click();
const plan3 = modal.getByTestId("plan-entry").last();
const days3 = plan3.getByTestId("repeating-plan-weekdays");
await days3.click();
await days3.getByRole("checkbox", { name: "Select all" }).check();
await days3.click(); // close
await plan3.getByTestId("repeating-plan-time").fill("09:10");
await plan3.getByTestId("repeating-plan-active").click();
// check next plans
await expect(modal.getByTestId("plan-preview-title")).toHaveText("Next plan #3");
await expect(modal.getByTestId("target-text")).toContainText("9:10 AM");
// disable plan #3
await plan3.getByTestId("repeating-plan-active").click();
await expect(modal.getByTestId("plan-preview-title")).toHaveText("Next plan #2");
await expect(modal.getByTestId("target-text")).toContainText("9:20 AM");
// change plan #2 to yesterday
await days2.click();
await days2.getByRole("checkbox", { name: tomorrow }).click(); // uncheck
await days2.getByRole("checkbox", { name: yesterday }).click(); // check
await days2.click(); // close
// no changes yet
await expect(modal.getByTestId("plan-preview-title")).toHaveText("Next plan #2");
await expect(modal.getByTestId("target-text")).toContainText("9:20 AM");
// apply
await plan2.getByTestId("repeating-plan-apply").click();
await expect(modal.getByTestId("plan-preview-title")).toHaveText("Next plan #1");
await expect(modal.getByTestId("target-text")).toContainText("9:30 AM");
});
test("repeating plan persistence", async ({ page }) => {
await page.goto("/");
const tomorrow = getWeekday(1);
let lp1 = await page.getByTestId("loadpoint").first();
await lp1
.getByTestId("change-vehicle")
.locator("select")
.selectOption("Vehicle with SoC with Capacity");
await lp1.getByTestId("charging-plan").getByRole("button", { name: "none" }).click();
let modal = await page.getByTestId("charging-plan-modal");
await modal.getByRole("button", { name: "Add repeating plan" }).click();
const plan = modal.getByTestId("plan-entry").nth(1);
await plan.getByTestId("repeating-plan-weekdays").click();
await plan.getByRole("checkbox", { name: "Select all" }).click(); // check all
await plan.getByRole("checkbox", { name: "Select all" }).click(); // uncheck all
await plan.getByRole("checkbox", { name: tomorrow }).check();
await plan.getByTestId("repeating-plan-time").fill("09:20");
await plan.getByTestId("repeating-plan-active").click();
await expect(modal.getByTestId("plan-preview-title")).toHaveText("Next plan #2");
await expect(modal.getByTestId("target-text")).toContainText("9:20 AM");
await restart(CONFIG);
await page.goto("/");
lp1 = await page.getByTestId("loadpoint").first();
await lp1
.getByTestId("change-vehicle")
.locator("select")
.selectOption("Vehicle with SoC with Capacity");
await lp1
.getByTestId("charging-plan")
.getByRole("button", { name: "tomorrow 9:20 AM" })
.click();
modal = await page.getByTestId("charging-plan-modal");
await expect(modal.getByTestId("plan-entry")).toHaveCount(2);
await expect(modal.getByTestId("plan-preview-title")).toHaveText("Next plan #2");
await expect(modal.getByTestId("target-text")).toContainText("9:20 AM");
});
});

54
util/time.go Normal file
View file

@ -0,0 +1,54 @@
package util
import (
"fmt"
"time"
)
// GetNextOccurrence returns the next occurrence of the given time on the specified weekdays.
func GetNextOccurrence(weekdays []int, timeStr string, tz string) (time.Time, error) {
loc, err := time.LoadLocation(tz)
if err != nil {
return time.Time{}, fmt.Errorf("invalid timezone: %w", err)
}
parsedTime, err := time.Parse("15:04", timeStr)
if err != nil {
return time.Time{}, fmt.Errorf("invalid time format, expected HH:MM: %w", err)
}
hour, minute := parsedTime.Hour(), parsedTime.Minute()
now := time.Now().In(loc)
target := time.Date(
now.Year(), now.Month(), now.Day(),
hour, minute, 0, 0, loc,
)
// If the target time has passed today, start from tomorrow
if target.Before(now) {
target = target.AddDate(0, 0, 1)
}
// Check the next 7 days for a valid match
for i := 0; i < 7; i++ {
weekday := int(target.Weekday())
if contains(weekdays, weekday) {
return target, nil
}
target = target.AddDate(0, 0, 1)
}
return time.Time{}, fmt.Errorf("no valid weekday found")
}
// helper function to check if a slice contains a value
func contains(slice []int, val int) bool {
for _, item := range slice {
if item == val {
return true
}
}
return false
}