Planner: add precondition phase before departure (#20431)

This commit is contained in:
andig 2025-04-25 14:10:09 +02:00 • committed by GitHub
parent c4be8e918d
commit 832012ef15
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
41 changed files with 746 additions and 222 deletions

View file

@ -1,9 +1,10 @@
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"`
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"` // target soc
Precondition int64 `json:"precondition"` // precondition duration in seconds
Active bool `json:"active"` // active flag
}

View file

@ -115,7 +115,7 @@ import { optionStep, fmtEnergy } from "../../utils/energyOptions.js";
import { defineComponent, type PropType } from "vue";
import type { CURRENCY, Timeout, Vehicle } from "assets/js/types/evcc.js";
import type { StaticPlan, StaticSocPlan, StaticEnergyPlan, RepeatingPlan } from "./types.js";
import type { Forecast } from "../../utils/forecast.ts";
const ONE_MINUTE = 60 * 1000;
export default defineComponent({
@ -139,6 +139,7 @@ export default defineComponent({
planEnergy: Number,
planTime: String,
planTimeUnreachable: Boolean,
planPrecondition: { type: Number, default: 0 },
planOverrun: Number,
rangePerSoc: Number,
smartCostType: String,
@ -149,6 +150,7 @@ export default defineComponent({
capacity: Number,
vehicleSoc: Number,
vehicleLimitSoc: Number,
forecast: Object as PropType<Forecast>,
},
data() {
return {
@ -179,12 +181,20 @@ export default defineComponent({
if (this.socBasedPlanning) {
const plan = this.vehicle?.plan as StaticSocPlan;
if (plan) {
return { soc: plan.soc, time: new Date(plan.time) };
return {
soc: plan.soc,
time: new Date(plan.time),
precondition: plan.precondition,
};
}
return null;
}
if (this.planEnergy && this.planTime) {
return { energy: this.planEnergy, time: new Date(this.planTime) };
return {
energy: this.planEnergy,
time: new Date(this.planTime),
precondition: this.planPrecondition,
};
}
return null;
},
@ -304,12 +314,15 @@ export default defineComponent({
},
updateStaticPlan(plan: StaticPlan): void {
const timeISO = plan.time.toISOString();
const params = this.socBasedPlanning ? { precondition: plan.precondition } : undefined;
if (this.socBasedPlanning) {
const p = plan as StaticSocPlan;
api.post(`${this.apiVehicle}plan/soc/${p.soc}/${timeISO}`);
api.post(`${this.apiVehicle}plan/soc/${p.soc}/${timeISO}`, null, { params });
} else {
const p = plan as StaticEnergyPlan;
api.post(`${this.apiLoadpoint}plan/energy/${p.energy}/${timeISO}`);
api.post(`${this.apiLoadpoint}plan/energy/${p.energy}/${timeISO}`, null, {
params,
});
}
},
removeStaticPlan(): void {

View file

@ -14,22 +14,27 @@
<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">
<div class="col-3">
<label :for="formId('weekdays')">
{{ $t("main.chargingPlan.weekdays") }}
</label>
</div>
<div class="col-6 col-lg-2">
<div class="col-2">
<label :for="formId('time')">
{{ $t("main.chargingPlan.time") }}
</label>
</div>
<div class="col-6 col-lg-3">
<div :class="showPrecondition ? 'col-2' : 'col-3'">
<label :for="formId('goal')">
{{ $t("main.chargingPlan.goal") }}
</label>
</div>
<div class="col-6 col-lg-1">
<div v-if="showPrecondition" class="col-1">
<label :for="formId('precondition')">
{{ $t("main.chargingPlan.preconditionShort") }}
</label>
</div>
<div class="col-1">
<label :for="formId('active')"> {{ $t("main.chargingPlan.active") }} </label>
</div>
</div>
@ -76,7 +81,7 @@
{{ $t("main.chargingPlan.goal") }}
</label>
</div>
<div class="col-7 col-lg-3 mb-2 mb-lg-0">
<div :class="['col-7', showPrecondition ? 'col-lg-2' : 'col-lg-3', 'mb-2', 'mb-lg-0']">
<select
:id="formId('goal')"
v-model="selectedSoc"
@ -89,6 +94,21 @@
</option>
</select>
</div>
<div v-if="showPrecondition" class="col-5 d-lg-none col-form-label">
<label :for="formId('precondition')">
{{ $t("main.chargingPlan.preconditionLong") }}
</label>
</div>
<div
v-if="showPrecondition"
class="col-7 col-lg-1 mb-2 mb-lg-0 d-flex align-items-center"
>
<PreconditionSelect
:id="formId('precondition')"
v-model="selectedPrecondition"
testid="repeating-plan-precondition"
/>
</div>
<div class="col-5 d-lg-none col-form-label">
<label :for="formId('active')">
{{ $t("main.chargingPlan.active") }}
@ -132,6 +152,15 @@
</button>
</div>
</div>
<!-- Large screen precondition description -->
<div class="plan-id-inset">
<PreconditionSelect
:id="formId('precondition')"
v-model="selectedPrecondition"
testid="repeating-plan-precondition"
description-lg-only
/>
</div>
</div>
</template>
@ -141,12 +170,13 @@ import { distanceUnit } from "../../units.js";
import MultiSelect from "../Helper/MultiSelect.vue";
import formatter from "../../mixins/formatter.js";
import deepEqual from "../../utils/deepEqual.js";
import PreconditionSelect from "./PreconditionSelect.vue";
import type { SelectOption } from "../../types/evcc";
import { defineComponent, type PropType } from "vue";
export default defineComponent({
name: "ChargingPlanRepeatingSettings",
components: { MultiSelect },
components: { MultiSelect, PreconditionSelect },
mixins: [formatter],
props: {
number: Number,
@ -154,10 +184,12 @@ export default defineComponent({
time: String,
tz: String,
soc: Number,
precondition: Number,
showHeader: Boolean,
active: Boolean,
rangePerSoc: Number,
formIdPrefix: String,
showPrecondition: Boolean,
},
emits: ["updated", "removed"],
data() {
@ -166,6 +198,7 @@ export default defineComponent({
selectedTime: this.time,
selectedSoc: this.soc,
selectedActive: this.active,
selectedPrecondition: this.precondition,
};
},
computed: {
@ -174,7 +207,8 @@ export default defineComponent({
!deepEqual(this.weekdays, this.selectedWeekdays) ||
this.time !== this.selectedTime ||
this.soc !== this.selectedSoc ||
this.active !== this.selectedActive
this.active !== this.selectedActive ||
this.precondition !== this.selectedPrecondition
);
},
showApply(): boolean {
@ -208,6 +242,9 @@ export default defineComponent({
active(newValue: boolean) {
this.selectedActive = newValue;
},
precondition(newValue: number) {
this.selectedPrecondition = newValue;
},
},
methods: {
id(): number {
@ -231,6 +268,7 @@ export default defineComponent({
soc: this.selectedSoc,
tz: this.tz,
active: this.selectedActive,
precondition: this.selectedPrecondition,
};
if (forceSave || !this.selectedActive) {
@ -241,6 +279,9 @@ export default defineComponent({
});
</script>
<style scoped>
.plan-id-inset {
margin-left: 2.5rem;
}
.plan-id {
width: 2.5rem;
color: var(--evcc-gray);

View file

@ -11,23 +11,28 @@
</h5>
<div class="row d-none d-lg-flex mb-2">
<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'">
<div v-if="multiplePlans" class="plan-id d-flex"></div>
<div class="col-3">
<label :for="formId('day')">
{{ $t("main.chargingPlan.day") }}
</label>
</div>
<div class="col-6 col-lg-2">
<div class="col-2">
<label :for="formId('time')">
{{ $t("main.chargingPlan.time") }}
</label>
</div>
<div class="col-6 col-lg-3">
<div :class="showPrecondition ? 'col-2' : 'col-3'">
<label :for="formId('goal')">
{{ $t("main.chargingPlan.goal") }}
</label>
</div>
<div class="col-6 col-lg-1">
<div v-if="showPrecondition" class="col-1">
<label :for="formId('precondition')">
{{ $t("main.chargingPlan.preconditionShort") }}
</label>
</div>
<div class="col-1">
<label :for="formId('active')"> {{ $t("main.chargingPlan.active") }} </label>
</div>
</div>
@ -43,7 +48,7 @@
{{ $t("main.chargingPlan.day") }}
</label>
</div>
<div :class="['col-7', multiplePlans ? 'col-lg-3' : 'col-lg-4', 'mb-2', 'mb-lg-0']">
<div class="col-7 col-lg-3 mb-2 mb-lg-0">
<select
:id="formId('day')"
v-model="selectedDay"
@ -78,7 +83,7 @@
{{ $t("main.chargingPlan.goal") }}
</label>
</div>
<div class="col-7 col-lg-3 mb-2 mb-lg-0">
<div :class="['col-7', showPrecondition ? 'col-lg-2' : 'col-lg-3', 'mb-2', 'mb-lg-0']">
<select
v-if="socBasedPlanning"
:id="formId('goal')"
@ -104,13 +109,28 @@
</option>
</select>
</div>
<div v-if="showPrecondition" class="col-5 d-lg-none col-form-label">
<label :for="formId('precondition')">
{{ $t("main.chargingPlan.preconditionLong") }}
</label>
</div>
<div
v-if="showPrecondition"
class="col-7 col-lg-1 mb-2 mb-lg-0 d-flex align-items-center"
>
<PreconditionSelect
:id="formId('precondition')"
v-model="selectedPrecondition"
testid="static-plan-precondition"
/>
</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">
<div class="col-3 col-lg-1 d-flex align-items-center">
<div class="form-check form-switch my-1">
<input
:id="formId('active')"
class="form-check-input"
@ -124,7 +144,9 @@
/>
</div>
</div>
<div class="col-5 col-lg-2 d-flex align-items-center">
<div
class="col-4 col-lg-2 d-flex align-items-center justify-content-end justify-content-lg-start"
>
<button
v-if="dataChanged && !isNew"
type="button"
@ -138,6 +160,15 @@
</button>
</div>
</div>
<!-- Large screen precondition description -->
<div :class="multiplePlans ? 'plan-id-insert' : ''">
<PreconditionSelect
:id="formId('precondition')"
v-model="selectedPrecondition"
testid="static-plan-precondition"
description-lg-only
/>
</div>
<p class="mb-0" data-testid="plan-entry-warnings">
<span v-if="timeInThePast" class="d-block text-danger my-2">
{{ $t("main.targetCharge.targetIsInThePast") }}
@ -153,6 +184,7 @@ import { distanceUnit } from "../../units.js";
import formatter from "../../mixins/formatter.js";
import { energyOptions } from "../../utils/energyOptions.js";
import { defineComponent } from "vue";
import PreconditionSelect from "./PreconditionSelect.vue";
const LAST_TARGET_TIME_KEY = "last_target_time";
const LAST_SOC_GOAL_KEY = "last_soc_goal";
@ -161,6 +193,7 @@ const DEFAULT_TARGET_TIME = "7:00";
export default defineComponent({
name: "ChargingPlanStaticSettings",
components: { PreconditionSelect },
mixins: [formatter],
props: {
id: [String, Number],
@ -172,6 +205,8 @@ export default defineComponent({
capacity: Number,
socBasedPlanning: Boolean,
multiplePlans: Boolean,
precondition: Number,
showPrecondition: Boolean,
},
emits: ["static-plan-updated", "static-plan-removed", "plan-preview"],
data() {
@ -181,6 +216,7 @@ export default defineComponent({
selectedSoc: this.soc,
selectedEnergy: this.energy,
active: false,
selectedPrecondition: 0,
};
},
computed: {
@ -215,6 +251,7 @@ export default defineComponent({
energy: this.energy,
day: this.fmtDayString(t),
time: this.fmtTimeString(t),
precondition: this.precondition,
};
},
dataChanged() {
@ -224,7 +261,8 @@ export default defineComponent({
const goalChanged = this.socBasedPlanning
? this.originalData.soc != this.selectedSoc
: this.originalData.energy != this.selectedEnergy;
return dateChanged || goalChanged;
const preconditionChanged = this.originalData.precondition != this.selectedPrecondition;
return dateChanged || goalChanged || preconditionChanged;
},
isNew() {
return !this.time && (!this.soc || !this.energy);
@ -251,6 +289,12 @@ export default defineComponent({
isNew(value) {
this.active = !value;
},
precondition(value) {
this.selectedPrecondition = value;
},
selectedPrecondition() {
this.preview();
},
},
mounted() {
this.initInputFields();
@ -325,6 +369,7 @@ export default defineComponent({
time: this.selectedDate,
soc: this.selectedSoc,
energy: this.selectedEnergy,
precondition: this.selectedPrecondition,
});
},
preview(force = false) {
@ -335,6 +380,7 @@ export default defineComponent({
time: this.selectedDate,
soc: this.selectedSoc,
energy: this.selectedEnergy,
precondition: this.selectedPrecondition,
});
},
toggle(e: Event) {
@ -367,6 +413,9 @@ export default defineComponent({
});
</script>
<style scoped>
.plan-id-insert {
margin-left: 2.5rem;
}
.plan-id {
width: 2.5rem;
color: var(--evcc-gray);

View file

@ -8,6 +8,7 @@
:formIdPrefix="formIdPrefix"
v-bind="plan"
:rangePerSoc="rangePerSoc"
:showPrecondition="showPrecondition"
@updated="updatePlan(index, $event)"
@removed="removePlan(index)"
/>
@ -48,6 +49,7 @@ export default defineComponent({
id: [Number, String],
rangePerSoc: Number,
plans: { type: Array as PropType<RepeatingPlan[]>, default: () => [] },
showPrecondition: Boolean,
},
emits: ["updated"],
computed: {
@ -62,6 +64,7 @@ export default defineComponent({
weekdays: DEFAULT_WEEKDAYS,
time: DEFAULT_TARGET_TIME,
soc: DEFAULT_TARGET_SOC,
precondition: 0,
active: false,
tz: this.timezone(),
};

View file

@ -10,7 +10,8 @@
:range-per-soc="rangePerSoc"
:soc-per-kwh="socPerKwh"
:soc-based-planning="socBasedPlanning"
:multiplePlans="multiplePlans"
:multiple-plans="multiplePlans"
:show-precondition="showPrecondition"
@static-plan-updated="(data) => updateStaticPlan({ index: 0, ...data })"
@static-plan-removed="() => removeStaticPlan(0)"
@plan-preview="previewStaticPlan"
@ -29,6 +30,7 @@
:id="id"
:rangePerSoc="rangePerSoc"
:plans="repeatingPlans"
:show-precondition="showPrecondition"
@updated="updateRepeatingPlans"
/>
</div>
@ -59,8 +61,8 @@
</span>
</div>
</h5>
<ChargingPlanWarnings v-bind="chargingPlanWarningsProps" />
<ChargingPlanPreview v-bind="chargingPlanPreviewProps" />
<ChargingPlanWarnings v-bind="chargingPlanWarningsProps" />
</div>
</template>
@ -72,12 +74,12 @@ import RepeatingSettings from "./PlansRepeatingSettings.vue";
import Warnings from "./Warnings.vue";
import formatter from "../../mixins/formatter.js";
import collector from "../../mixins/collector.js";
import api, { allowClientError } from "../../api.js";
import api from "../../api.js";
import CustomSelect from "../Helper/CustomSelect.vue";
import deepEqual from "../../utils/deepEqual.js";
import convertRates from "../../utils/convertRates";
import { defineComponent, type PropType } from "vue";
import type { Vehicle, PartialBy, Timeout, Tariff, SelectOption, CURRENCY } from "../../types/evcc";
import type { Vehicle, PartialBy, Timeout, SelectOption, CURRENCY } from "../../types/evcc";
import type {
StaticPlan,
RepeatingPlan,
@ -86,8 +88,7 @@ import type {
StaticEnergyPlan,
PlanResponse,
} from "./types.js";
const TARIFF_CACHE_TIME = 300000; // 5 minutes
import type { Forecast } from "../../utils/forecast.ts";
export default defineComponent({
name: "ChargingPlansSettings",
@ -118,12 +119,12 @@ export default defineComponent({
vehicle: Object as PropType<Vehicle>,
vehicleLimitSoc: Number,
planOverrun: Number,
forecast: Object as PropType<Forecast>,
},
emits: ["static-plan-removed", "static-plan-updated", "repeating-plans-updated"],
data() {
return {
staticPlanPreview: {} as StaticPlan,
tariff: {} as Tariff,
plan: {} as PlanWrapper,
activeTab: "time",
debounceTimer: null as Timeout,
@ -145,7 +146,8 @@ export default defineComponent({
return this.collectProps(Warnings);
},
chargingPlanPreviewProps(): any {
const { rates } = this.tariff;
const forecastSlots = this.forecast?.planner || [];
const rates = convertRates(forecastSlots);
const { duration, plan, power, planTime } = this.plan;
const targetTime = planTime ? new Date(planTime) : null;
const { currency, smartCostType } = this;
@ -177,6 +179,12 @@ export default defineComponent({
nextPlanTitle(): string {
return `${this.$t("main.targetCharge.nextPlan")} #${this.nextPlanId}`;
},
showPrecondition(): boolean {
// only show option if planner forecast has different values
const slots = this.forecast?.planner || [];
const values = new Set(slots.map(({ value }) => value));
return values.size > 1;
},
},
watch: {
effectivePlanTime(newValue: string) {
@ -223,7 +231,6 @@ export default defineComponent({
}
},
async updateActivePlan(): Promise<void> {
await this.updateTariff();
try {
const res = await this.apiFetchPlan(`loadpoints/${this.id}/plan`);
this.plan = res?.data.result ?? ({} as PlanWrapper);
@ -234,8 +241,10 @@ export default defineComponent({
},
async fetchStaticPreviewSoc(plan: StaticSocPlan): Promise<PlanResponse | undefined> {
const timeISO = plan.time.toISOString();
const params = plan.precondition ? { precondition: plan.precondition } : undefined;
return await this.apiFetchPlan(
`loadpoints/${this.id}/plan/static/preview/soc/${plan.soc}/${timeISO}`
`loadpoints/${this.id}/plan/static/preview/soc/${plan.soc}/${timeISO}`,
params
);
},
async fetchRepeatingPreview(
@ -251,10 +260,14 @@ export default defineComponent({
`loadpoints/${this.id}/plan/static/preview/energy/${plan.energy}/${timeISO}`
);
},
async apiFetchPlan(url: string): Promise<PlanResponse | undefined> {
async apiFetchPlan(
url: string,
params?: Record<string, unknown>
): Promise<PlanResponse | undefined> {
try {
const res = (await api.get(url, {
validateStatus: (code) => [200, 404].includes(code),
params,
})) as PlanResponse;
if (res.status === 404) {
return { data: { result: {} as PlanWrapper } } as PlanResponse;
@ -269,8 +282,6 @@ export default defineComponent({
// only show preview if no plan is active
if (!this.noActivePlan) return;
await this.updateTariff();
try {
let planRes: PlanResponse | undefined = undefined;
@ -282,12 +293,14 @@ export default defineComponent({
planRes = await this.fetchStaticPreviewSoc({
soc: plan.soc,
time: plan.time,
precondition: plan.precondition,
});
} else {
plan = plan as StaticEnergyPlan;
planRes = await this.fetchStaticPreviewEnergy({
energy: plan.energy,
time: plan.time,
precondition: plan.precondition,
});
}
} else {
@ -296,33 +309,23 @@ export default defineComponent({
if (!plan) {
return;
}
const { weekdays, soc, time, tz } = plan;
const { weekdays, soc, time, tz, precondition } = plan;
if (weekdays.length === 0) {
return;
}
planRes = await this.fetchRepeatingPreview({ weekdays, soc, time, tz });
planRes = await this.fetchRepeatingPreview({
weekdays,
soc,
time,
tz,
precondition,
});
}
this.plan = planRes?.data.result ?? ({} as PlanWrapper);
} catch (e) {
console.error(e);
}
},
async updateTariff(): Promise<void> {
// cache tariff for 5 minutes
if (
this.tariff?.lastUpdate &&
Date.now() - this.tariff.lastUpdate.getTime() <= TARIFF_CACHE_TIME
) {
return;
}
const res = await api.get(`tariff/planner`, allowClientError);
this.tariff = {
rates: res.status === 404 ? [] : convertRates(res.data.result.rates),
lastUpdate: new Date(),
};
},
async updatePlanPreviewDebounced(): Promise<void> {
if (!this.debounceTimer) {
await this.updatePreviewPlan();

View file

@ -0,0 +1,134 @@
<template>
<!-- small screen (select, description), large screen (checkbox only) -->
<template v-if="!descriptionLgOnly">
<div class="form-check d-none d-lg-block" :style="{ padding: '2px' }">
<input
:id="id"
class="form-check-input d-none d-lg-block"
type="checkbox"
tabindex="0"
:data-testid="`${testid}-lg-toggle`"
:checked="enabled"
@change="toggle"
/>
</div>
<div class="d-flex flex-column w-100">
<select
:id="`${id}-sm`"
v-model="localValue"
class="form-select mx-0 d-lg-none"
:data-testid="`${testid}-select`"
@change="onChange"
>
<option :value="0">
{{ $t("main.chargingPlan.preconditionOptionNo") }}
</option>
<option v-for="opt in options" :key="opt.value" :value="opt.value">
{{ opt.name }}
</option>
</select>
<small v-if="enabled" class="d-block d-lg-none mt-1 mb-2">
{{ $t("main.chargingPlan.preconditionDescription", { duration: valueFmt }) }}
</small>
</div>
</template>
<!-- large screen (description with select) -->
<template v-else>
<p v-if="enabled" class="m-2 d-none d-lg-block">
<strong>{{ $t("main.chargingPlan.preconditionLong") }}: </strong>
<i18n-t tag="span" scope="global" keypath="main.chargingPlan.preconditionDescription">
<template #duration>
<CustomSelect
:id="`${id}-lg`"
class="d-inline-flex"
:options="options"
:selected="localValue"
:data-testid="`${testid}-lg-select`"
@change="onChange"
>
<u>{{ valueFmt }}</u>
</CustomSelect>
</template>
</i18n-t>
</p>
</template>
</template>
<script lang="ts">
import { defineComponent } from "vue";
import CustomSelect from "../Helper/CustomSelect.vue";
import formatter from "../../mixins/formatter.js";
export default defineComponent({
name: "PreconditionSelect",
components: { CustomSelect },
mixins: [formatter],
props: {
id: String,
modelValue: { type: Number, default: 0 },
testid: String,
descriptionLgOnly: Boolean,
},
emits: ["update:modelValue"],
data() {
return {
localValue: this.modelValue,
};
},
computed: {
enabled() {
return this.localValue > 0;
},
options() {
const HOUR = 60 * 60;
const HALF_HOUR = 0.5 * HOUR;
const ONE_HOUR = 1 * HOUR;
const TWO_HOURS = 2 * HOUR;
const EVERYTHING = 7 * 24 * HOUR;
const options = [HALF_HOUR, ONE_HOUR, TWO_HOURS, EVERYTHING];
// support custom values (via API)
if (this.localValue && !options.includes(this.localValue)) {
options.push(this.localValue);
}
return options.map((s) => ({
value: s,
name:
s === EVERYTHING
? this.$t("main.chargingPlan.preconditionOptionAll")
: this.fmtDurationLong(s),
}));
},
valueFmt() {
return this.options.find((o: { value: number }) => o.value === this.localValue)?.name;
},
},
watch: {
modelValue(newValue) {
this.localValue = newValue;
},
},
methods: {
toggle() {
const DEFAULT_PRECONDITION = 3600; // 1 hour
const newValue = this.localValue > 0 ? 0 : DEFAULT_PRECONDITION;
this.localValue = newValue;
this.$emit("update:modelValue", newValue);
},
onChange(event: Event) {
const value = parseInt((event.target as HTMLSelectElement).value);
this.$emit("update:modelValue", value);
},
},
});
</script>
<style scoped>
.form-check-input {
margin-left: 0.1rem;
}
</style>

View file

@ -6,6 +6,7 @@ export interface RepeatingPlan {
tz: string; // timezone like "Europe/Berlin"
soc: number;
active: boolean;
precondition: number;
}
export interface PlanWrapper {
@ -26,9 +27,11 @@ export type StaticPlan = StaticSocPlan | StaticEnergyPlan;
export interface StaticSocPlan {
soc: number;
time: Date;
precondition: number;
}
export interface StaticEnergyPlan {
energy: number;
time: Date;
precondition: number;
}

View file

@ -165,6 +165,7 @@ export default {
planProjectedEnd: String,
planOverrun: Number,
planEnergy: Number,
planPrecondition: Number,
planTime: String,
effectivePlanTime: String,
effectivePlanSoc: Number,
@ -204,6 +205,7 @@ export default {
multipleLoadpoints: Boolean,
gridConfigured: Boolean,
pvConfigured: Boolean,
forecast: Object, // as PropType<Forecast>,
},
data() {
return {
@ -281,6 +283,9 @@ export default {
batteryBoostActive() {
return this.batteryBoost && this.charging && !["off", "now"].includes(this.mode);
},
plannerForecast() {
return this.forecast?.planner;
},
},
watch: {
phaseRemaining() {

View file

@ -25,6 +25,7 @@
:gridConfigured="gridConfigured"
:pvConfigured="pvConfigured"
:batteryConfigured="batteryConfigured"
:forecast="forecast"
class="h-100"
:class="{ 'loadpoint-unselected': !selected(index) }"
@click="goTo(index)"
@ -74,6 +75,7 @@ export default {
gridConfigured: Boolean,
pvConfigured: Boolean,
batteryConfigured: Boolean,
forecast: Object, // as PropType<Forecast>,
},
emits: ["index-changed"],
data() {

View file

@ -54,6 +54,7 @@
:pvConfigured="pvConfigured"
:batteryConfigured="batteryConfigured"
:batterySoc="batterySoc"
:forecast="forecast"
:selectedIndex="selectedLoadpointIndex"
@index-changed="selectedLoadpointChanged"
/>
@ -131,7 +132,7 @@ export default {
sponsor: { type: Object, default: () => ({}) },
smartCostType: String,
fatal: Object,
forecast: Object,
forecast: Object, // as PropType<Forecast>,
},
computed: {
batteryConfigured() {

View file

@ -111,12 +111,14 @@ export default {
chargerStatusReason: String,
phaseAction: String,
phaseRemainingInterpolated: Number,
forecast: Object, // as PropType<Forecast>,
planActive: Boolean,
planEnergy: Number,
planProjectedStart: String,
planProjectedEnd: String,
planTime: String,
planTimeUnreachable: Boolean,
planPrecondition: Number,
planOverrun: Number,
pvAction: String,
pvRemainingInterpolated: Number,

View file

@ -140,6 +140,19 @@ export default defineComponent({
}
return result;
},
fmtDurationLong(seconds: number) {
// @ts-expect-error - Intl.DurationFormat is a new API not yet in TS types, see https://github.com/microsoft/TypeScript/issues/60608
if (!Intl.DurationFormat) {
// old browser fallback
return this.fmtDuration(seconds);
}
const hours = Math.floor(seconds / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
// @ts-expect-error - Intl.DurationFormat is a new API not yet in TS types, see https://github.com/microsoft/TypeScript/issues/60608
const formatter = new Intl.DurationFormat(this.$i18n?.locale, { style: "long" });
return formatter.format({ minutes, hours });
},
fmtDayString(date: Date) {
const YY = `${date.getFullYear()}`;
const MM = `${date.getMonth() + 1}`.padStart(2, "0");

View file

@ -46,13 +46,6 @@ export interface Tariff {
lastUpdate: Date;
}
// data from api with string-based timestamps
export interface RateRaw {
start: string;
end: string;
value: number;
}
export interface Rate {
start: Date;
end: Date;

View file

@ -1,14 +1,15 @@
import type { RateRaw, Rate } from "../types/evcc";
import type { Rate } from "../types/evcc";
import type { ForecastSlot } from "./forecast";
function convertRate(rate: RateRaw): Rate {
function convertRate(slot: ForecastSlot): Rate {
return {
start: new Date(rate.start),
end: new Date(rate.end),
value: rate.value,
start: new Date(slot.start),
end: new Date(slot.end),
value: slot.value,
};
}
export default function convertRates(rates: RateRaw[] | null): Rate[] {
if (!rates) return [];
return rates.map(convertRate);
export default function convertRates(slots: ForecastSlot[] | null): Rate[] {
if (!slots) return [];
return slots.map(convertRate);
}

View file

@ -31,6 +31,7 @@ export interface SolarDetails {
export interface Forecast {
grid?: ForecastSlot[];
co2?: ForecastSlot[];
planner?: ForecastSlot[];
solar?: SolarDetails;
}

View file

@ -70,6 +70,7 @@ const (
PlanTime = "planTime" // charge plan finish time goal
PlanEnergy = "planEnergy" // charge plan energy goal
PlanSoc = "planSoc" // charge plan soc goal
PlanPrecondition = "planPrecondition" // charge plan precondition duration
PlanActive = "planActive" // charge plan has determined current slot to be an active slot
PlanProjectedStart = "planProjectedStart" // charge plan start time (earliest slot)
PlanProjectedEnd = "planProjectedEnd" // charge plan ends (end of last slot)

View file

@ -140,11 +140,12 @@ type Loadpoint struct {
socEstimator *soc.Estimator
// charge planning
planner *planner.Planner
planTime time.Time // time goal
planEnergy float64 // Plan charge energy in kWh (dumb vehicles)
planSlotEnd time.Time // current plan slot end time
planActive bool // charge plan exists and has a currently active slot
planner *planner.Planner
planTime time.Time // time goal
planPrecondition time.Duration // precondition duration
planEnergy float64 // Plan charge energy in kWh (dumb vehicles)
planSlotEnd time.Time // current plan slot end time
planActive bool // charge plan exists and has a currently active slot
// cached state
status api.ChargeStatus // Charger status
@ -342,8 +343,9 @@ func (lp *Loadpoint) restoreSettings() {
t, err1 := lp.settings.Time(keys.PlanTime)
v, err2 := lp.settings.Float(keys.PlanEnergy)
d, _ := lp.settings.Int(keys.PlanPrecondition)
if err1 == nil && err2 == nil {
lp.setPlanEnergy(t, v)
lp.setPlanEnergy(t, time.Duration(d)*time.Second, v)
}
}
@ -657,6 +659,7 @@ func (lp *Loadpoint) Prepare(uiChan chan<- util.Param, pushChan chan<- push.Even
// restored settings
lp.publish(keys.PlanTime, lp.planTime)
lp.publish(keys.PlanEnergy, lp.planEnergy)
lp.publish(keys.PlanPrecondition, lp.planPrecondition)
lp.publish(keys.LimitSoc, lp.limitSoc)
lp.publish(keys.LimitEnergy, lp.limitEnergy)
@ -953,7 +956,7 @@ func (lp *Loadpoint) repeatingPlanning() bool {
if !lp.socBasedPlanning() {
return false
}
_, _, id := lp.NextVehiclePlan()
_, _, _, id := lp.NextVehiclePlan()
return id > 1
}

View file

@ -114,17 +114,19 @@ type API interface {
//
// GetPlanEnergy returns the charge plan energy
GetPlanEnergy() (time.Time, float64)
GetPlanEnergy() (time.Time, time.Duration, float64)
// SetPlanEnergy sets the charge plan energy
SetPlanEnergy(time.Time, float64) error
// GetPlanGoal returns the plan goal and if the goal is soc based
SetPlanEnergy(time.Time, time.Duration, float64) error
// GetPlanGoal returns the plan goal, precondition duration and if the goal is soc based
GetPlanGoal() (float64, bool)
// GetPlanRequiredDuration returns required duration of plan to reach the goal from current state
GetPlanRequiredDuration(goal, maxPower float64) time.Duration
// GetPlanPreCondDuration returns the precondition duration
GetPlanPreCondDuration() time.Duration
// SocBasedPlanning determines if the planner is soc based
SocBasedPlanning() bool
// GetPlan creates a charging plan
GetPlan(targetTime time.Time, requiredDuration time.Duration) api.Rates
GetPlan(targetTime time.Time, requiredDuration, precondition time.Duration) api.Rates
// GetSocConfig returns the soc poll settings
GetSocConfig() SocConfig

View file

@ -26,6 +26,7 @@ type DynamicConfig struct {
SmartCostLimit *float64 `json:"smartCostLimit"`
PlanEnergy float64 `json:"planEnergy"`
PlanTime time.Time `json:"planTime"`
PlanPrecondition int64 `json:"planPrecondition"`
LimitEnergy float64 `json:"limitEnergy"`
LimitSoc int `json:"limitSoc"`
@ -56,7 +57,7 @@ func (payload DynamicConfig) Apply(lp API) error {
lp.SetPriority(payload.Priority)
lp.SetSmartCostLimit(payload.SmartCostLimit)
lp.SetThresholds(payload.Thresholds)
lp.SetPlanEnergy(payload.PlanTime, payload.PlanEnergy)
lp.SetPlanEnergy(payload.PlanTime, time.Duration(payload.PlanPrecondition)*time.Second, payload.PlanEnergy)
lp.SetLimitEnergy(payload.LimitEnergy)
lp.SetLimitSoc(payload.LimitSoc)

View file

@ -420,26 +420,27 @@ func (mr *MockAPIMockRecorder) GetPhasesConfigured() *gomock.Call {
}
// GetPlan mocks base method.
func (m *MockAPI) GetPlan(targetTime time.Time, requiredDuration time.Duration) api.Rates {
func (m *MockAPI) GetPlan(targetTime time.Time, requiredDuration, precondition time.Duration) api.Rates {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "GetPlan", targetTime, requiredDuration)
ret := m.ctrl.Call(m, "GetPlan", targetTime, requiredDuration, precondition)
ret0, _ := ret[0].(api.Rates)
return ret0
}
// GetPlan indicates an expected call of GetPlan.
func (mr *MockAPIMockRecorder) GetPlan(targetTime, requiredDuration any) *gomock.Call {
func (mr *MockAPIMockRecorder) GetPlan(targetTime, requiredDuration, precondition any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetPlan", reflect.TypeOf((*MockAPI)(nil).GetPlan), targetTime, requiredDuration)
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetPlan", reflect.TypeOf((*MockAPI)(nil).GetPlan), targetTime, requiredDuration, precondition)
}
// GetPlanEnergy mocks base method.
func (m *MockAPI) GetPlanEnergy() (time.Time, float64) {
func (m *MockAPI) GetPlanEnergy() (time.Time, time.Duration, float64) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "GetPlanEnergy")
ret0, _ := ret[0].(time.Time)
ret1, _ := ret[1].(float64)
return ret0, ret1
ret1, _ := ret[1].(time.Duration)
ret2, _ := ret[2].(float64)
return ret0, ret1, ret2
}
// GetPlanEnergy indicates an expected call of GetPlanEnergy.
@ -463,6 +464,20 @@ func (mr *MockAPIMockRecorder) GetPlanGoal() *gomock.Call {
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetPlanGoal", reflect.TypeOf((*MockAPI)(nil).GetPlanGoal))
}
// GetPlanPreCondDuration mocks base method.
func (m *MockAPI) GetPlanPreCondDuration() time.Duration {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "GetPlanPreCondDuration")
ret0, _ := ret[0].(time.Duration)
return ret0
}
// GetPlanPreCondDuration indicates an expected call of GetPlanPreCondDuration.
func (mr *MockAPIMockRecorder) GetPlanPreCondDuration() *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetPlanPreCondDuration", reflect.TypeOf((*MockAPI)(nil).GetPlanPreCondDuration))
}
// GetPlanRequiredDuration mocks base method.
func (m *MockAPI) GetPlanRequiredDuration(goal, maxPower float64) time.Duration {
m.ctrl.T.Helper()
@ -844,17 +859,17 @@ func (mr *MockAPIMockRecorder) SetPhasesConfigured(arg0 any) *gomock.Call {
}
// SetPlanEnergy mocks base method.
func (m *MockAPI) SetPlanEnergy(arg0 time.Time, arg1 float64) error {
func (m *MockAPI) SetPlanEnergy(arg0 time.Time, arg1 time.Duration, arg2 float64) error {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "SetPlanEnergy", arg0, arg1)
ret := m.ctrl.Call(m, "SetPlanEnergy", arg0, arg1, arg2)
ret0, _ := ret[0].(error)
return ret0
}
// SetPlanEnergy indicates an expected call of SetPlanEnergy.
func (mr *MockAPIMockRecorder) SetPlanEnergy(arg0, arg1 any) *gomock.Call {
func (mr *MockAPIMockRecorder) SetPlanEnergy(arg0, arg1, arg2 any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SetPlanEnergy", reflect.TypeOf((*MockAPI)(nil).SetPlanEnergy), arg0, arg1)
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SetPlanEnergy", reflect.TypeOf((*MockAPI)(nil).SetPlanEnergy), arg0, arg1, arg2)
}
// SetPriority mocks base method.

View file

@ -327,19 +327,19 @@ func (lp *Loadpoint) SetLimitEnergy(energy float64) {
}
// GetPlanEnergy returns plan target energy
func (lp *Loadpoint) GetPlanEnergy() (time.Time, float64) {
func (lp *Loadpoint) GetPlanEnergy() (time.Time, time.Duration, float64) {
lp.RLock()
defer lp.RUnlock()
return lp.getPlanEnergy()
}
// getPlanEnergy returns plan target energy
func (lp *Loadpoint) getPlanEnergy() (time.Time, float64) {
return lp.planTime, lp.planEnergy
func (lp *Loadpoint) getPlanEnergy() (time.Time, time.Duration, float64) {
return lp.planTime, lp.planPrecondition, lp.planEnergy
}
// setPlanEnergy sets plan target energy (no mutex)
func (lp *Loadpoint) setPlanEnergy(finishAt time.Time, energy float64) {
func (lp *Loadpoint) setPlanEnergy(finishAt time.Time, precondition time.Duration, energy float64) {
lp.planEnergy = energy
lp.publish(keys.PlanEnergy, energy)
lp.settings.SetFloat(keys.PlanEnergy, energy)
@ -347,11 +347,14 @@ func (lp *Loadpoint) setPlanEnergy(finishAt time.Time, energy float64) {
// remove plan
if energy == 0 {
finishAt = time.Time{}
precondition = 0
}
lp.planTime = finishAt
lp.publish(keys.PlanTime, finishAt)
lp.publish(keys.PlanPrecondition, precondition)
lp.settings.SetTime(keys.PlanTime, finishAt)
lp.settings.SetInt(keys.PlanPrecondition, int64(precondition.Seconds()))
if finishAt.IsZero() {
lp.setPlanActive(false)
@ -359,7 +362,7 @@ func (lp *Loadpoint) setPlanEnergy(finishAt time.Time, energy float64) {
}
// SetPlanEnergy sets plan target energy
func (lp *Loadpoint) SetPlanEnergy(finishAt time.Time, energy float64) error {
func (lp *Loadpoint) SetPlanEnergy(finishAt time.Time, precondition time.Duration, energy float64) error {
lp.Lock()
defer lp.Unlock()
@ -370,8 +373,8 @@ func (lp *Loadpoint) SetPlanEnergy(finishAt time.Time, energy float64) error {
lp.log.DEBUG.Printf("set plan energy: %.3gkWh @ %v", energy, finishAt.Round(time.Second).Local())
// apply immediately
if lp.planEnergy != energy || !lp.planTime.Equal(finishAt) {
lp.setPlanEnergy(finishAt, energy)
if lp.planEnergy != energy || lp.planPrecondition != precondition || !lp.planTime.Equal(finishAt) {
lp.setPlanEnergy(finishAt, precondition, energy)
lp.requestUpdate()
}

View file

@ -32,10 +32,11 @@ func (lp *Loadpoint) EffectivePriority() int {
}
type plan struct {
Id int
Start time.Time // last possible start time
End time.Time // user-selected finish time
Soc int
Id int
Start time.Time // last possible start time
End time.Time // user-selected finish time
Precondition time.Duration
Soc int
}
func (lp *Loadpoint) nextActivePlan(maxPower float64, plans []plan) *plan {
@ -59,20 +60,20 @@ func (lp *Loadpoint) nextActivePlan(maxPower float64, plans []plan) *plan {
}
// NextVehiclePlan returns the next vehicle plan time, soc and id
func (lp *Loadpoint) NextVehiclePlan() (time.Time, int, int) {
func (lp *Loadpoint) NextVehiclePlan() (time.Time, time.Duration, int, int) {
lp.RLock()
defer lp.RUnlock()
return lp.nextVehiclePlan()
}
// nextVehiclePlan returns the next vehicle plan time, soc and id
func (lp *Loadpoint) nextVehiclePlan() (time.Time, int, int) {
// nextVehiclePlan returns the next vehicle plan time, precondition duration, soc and id
func (lp *Loadpoint) nextVehiclePlan() (time.Time, time.Duration, int, int) {
if v := lp.GetVehicle(); v != nil {
var plans []plan
// static plan
if planTime, soc := vehicle.Settings(lp.log, v).GetPlanSoc(); soc != 0 {
plans = append(plans, plan{Id: 1, Soc: soc, End: planTime})
if planTime, precondition, soc := vehicle.Settings(lp.log, v).GetPlanSoc(); soc != 0 {
plans = append(plans, plan{Id: 1, Precondition: precondition, Soc: soc, End: planTime})
}
// repeating plans
@ -81,33 +82,34 @@ func (lp *Loadpoint) nextVehiclePlan() (time.Time, int, int) {
continue
}
time, err := util.GetNextOccurrence(rp.Weekdays, rp.Time, rp.Tz)
planTime, 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, End: time})
precondition := time.Duration(rp.Precondition) * time.Second
plans = append(plans, plan{Id: index + 2, Precondition: precondition, Soc: rp.Soc, End: planTime})
}
// calculate earliest required plan start
if plan := lp.nextActivePlan(lp.effectiveMaxPower(), plans); plan != nil {
return plan.End, plan.Soc, plan.Id
return plan.End, plan.Precondition, plan.Soc, plan.Id
}
}
return time.Time{}, 0, 0
return time.Time{}, 0, 0, 0
}
// EffectivePlanSoc returns the soc target for the current plan
func (lp *Loadpoint) EffectivePlanSoc() int {
_, soc, _ := lp.NextVehiclePlan()
_, _, soc, _ := lp.NextVehiclePlan()
return soc
}
// EffectivePlanId returns the id for the current plan
func (lp *Loadpoint) EffectivePlanId() int {
if lp.socBasedPlanning() {
_, _, id := lp.NextVehiclePlan()
_, _, _, id := lp.NextVehiclePlan()
return id
}
if lp.planEnergy > 0 {
@ -120,11 +122,11 @@ func (lp *Loadpoint) EffectivePlanId() int {
// EffectivePlanTime returns the effective plan time
func (lp *Loadpoint) EffectivePlanTime() time.Time {
if lp.socBasedPlanning() {
ts, _, _ := lp.NextVehiclePlan()
ts, _, _, _ := lp.NextVehiclePlan()
return ts
}
ts, _ := lp.GetPlanEnergy()
ts, _, _ := lp.GetPlanEnergy()
return ts
}

View file

@ -33,9 +33,9 @@ func (lp *Loadpoint) finishPlan() {
if lp.repeatingPlanning() {
return // noting to do
} else if !lp.socBasedPlanning() {
lp.setPlanEnergy(time.Time{}, 0)
lp.setPlanEnergy(time.Time{}, 0, 0)
} else if v := lp.GetVehicle(); v != nil {
vehicle.Settings(lp.log, v).SetPlanSoc(time.Time{}, 0)
vehicle.Settings(lp.log, v).SetPlanSoc(time.Time{}, 0, 0)
}
}
@ -70,21 +70,36 @@ func (lp *Loadpoint) GetPlanGoal() (float64, bool) {
defer lp.RUnlock()
if lp.socBasedPlanning() {
_, soc, _ := lp.nextVehiclePlan()
_, _, soc, _ := lp.nextVehiclePlan()
return float64(soc), true
}
_, limit := lp.getPlanEnergy()
_, _, limit := lp.getPlanEnergy()
return limit, false
}
// GetPlanPreCondDuration returns the plan precondition duration
func (lp *Loadpoint) GetPlanPreCondDuration() time.Duration {
lp.RLock()
defer lp.RUnlock()
if lp.socBasedPlanning() {
_, precondition, _, _ := lp.nextVehiclePlan()
return precondition
}
_, precondition, _ := lp.getPlanEnergy()
return precondition
}
// GetPlan creates a charging plan for given time and duration
func (lp *Loadpoint) GetPlan(targetTime time.Time, requiredDuration time.Duration) api.Rates {
// The plan is sorted by time
func (lp *Loadpoint) GetPlan(targetTime time.Time, requiredDuration, precondition time.Duration) api.Rates {
if lp.planner == nil || targetTime.IsZero() {
return nil
}
return lp.planner.Plan(requiredDuration, targetTime)
return lp.planner.Plan(requiredDuration, precondition, targetTime)
}
// plannerActive checks if the charging plan has a currently active slot
@ -132,7 +147,7 @@ func (lp *Loadpoint) plannerActive() (active bool) {
return false
}
plan := lp.GetPlan(planTime, requiredDuration)
plan := lp.GetPlan(planTime, requiredDuration, lp.GetPlanPreCondDuration())
if plan == nil {
return false
}

View file

@ -136,7 +136,7 @@ func (t *Planner) continuousPlan(rates api.Rates, start, end time.Time) api.Rate
return res
}
func (t *Planner) Plan(requiredDuration time.Duration, targetTime time.Time) api.Rates {
func (t *Planner) Plan(requiredDuration, precondition time.Duration, targetTime time.Time) api.Rates {
if t == nil || requiredDuration <= 0 {
return nil
}
@ -178,6 +178,9 @@ func (t *Planner) Plan(requiredDuration time.Duration, targetTime time.Time) api
// sort rates by price and time
slices.SortStableFunc(rates, sortByCost)
// for late start ensure that the last slot is the cheapest
rates, adjusted := splitPreconditionSlots(rates, precondition, targetTime)
// reduce planning horizon to available rates
if targetTime.After(last) {
// there is enough time for charging after end of current rates
@ -194,10 +197,62 @@ func (t *Planner) Plan(requiredDuration time.Duration, targetTime time.Time) api
requiredDuration -= durationAfterRates
}
// sort rates by price and time
slices.SortStableFunc(rates, sortByCost)
plan := t.plan(rates, requiredDuration, targetTime)
// correct plan slots to show original, non-adjusted prices
for i, r := range plan {
if rr, err := adjusted.At(r.Start); err == nil {
plan[i].Value = rr.Value
}
}
// sort plan by time
plan.Sort()
return plan
}
func splitPreconditionSlots(rates api.Rates, precondition time.Duration, targetTime time.Time) (api.Rates, api.Rates) {
var res, adjusted api.Rates
for _, r := range slices.Clone(rates) {
preCondStart := targetTime.Add(-precondition)
if !r.End.After(preCondStart) {
res = append(res, r)
continue
}
// split slot
if !r.Start.After(preCondStart) {
// keep the first part of the slot
res = append(res, api.Rate{
Start: r.Start,
End: preCondStart,
Value: r.Value,
})
// adjust the second part of the slot
r = api.Rate{
Start: preCondStart,
End: r.End,
Value: r.Value,
}
}
// set the value to 0 to include slot in the plan
res = append(res, api.Rate{
Start: r.Start,
End: r.End,
Value: 0,
})
// keep a copy of the adjusted slot
adjusted = append(adjusted, r)
}
return res, adjusted
}

View file

@ -133,7 +133,7 @@ func TestNilTariff(t *testing.T) {
clock: clock,
}
plan := p.Plan(time.Hour, clock.Now().Add(30*time.Minute))
plan := p.Plan(time.Hour, 0, clock.Now().Add(30*time.Minute))
assert.Equal(t, api.Rates{
{
Start: clock.Now(),
@ -155,7 +155,7 @@ func TestRatesError(t *testing.T) {
tariff: trf,
}
plan := p.Plan(time.Hour, clock.Now().Add(30*time.Minute))
plan := p.Plan(time.Hour, 0, clock.Now().Add(30*time.Minute))
assert.Equal(t, api.Rates{
{
Start: clock.Now(),
@ -184,10 +184,10 @@ func TestFlatTariffTargetInThePast(t *testing.T) {
},
}
plan := p.Plan(time.Hour, clock.Now().Add(30*time.Minute))
plan := p.Plan(time.Hour, 0, clock.Now().Add(30*time.Minute))
assert.Equal(t, simplePlan, plan, "expected simple plan")
plan = p.Plan(time.Hour, clock.Now().Add(-30*time.Minute))
plan = p.Plan(time.Hour, 0, clock.Now().Add(-30*time.Minute))
assert.Equal(t, simplePlan, plan, "expected simple plan")
}
@ -208,12 +208,12 @@ func TestFlatTariffLongSlots(t *testing.T) {
// that slots are not longer than 1 hour and with that context this is not a problem
// expect 00:00-01:00 UTC
plan := p.Plan(time.Hour, clock.Now().Add(2*time.Hour))
plan := p.Plan(time.Hour, 0, clock.Now().Add(2*time.Hour))
assert.Equal(t, api.Rate{Start: clock.Now(), End: clock.Now().Add(time.Hour)}, SlotAt(clock.Now(), plan))
assert.Equal(t, api.Rate{}, SlotAt(clock.Now().Add(time.Hour), plan))
// expect 00:00-01:00 UTC
plan = p.Plan(time.Hour, clock.Now().Add(time.Hour))
plan = p.Plan(time.Hour, 0, clock.Now().Add(time.Hour))
assert.Equal(t, api.Rate{Start: clock.Now(), End: clock.Now().Add(time.Hour)}, SlotAt(clock.Now(), plan))
}
@ -230,10 +230,10 @@ func TestTargetAfterKnownPrices(t *testing.T) {
tariff: trf,
}
plan := p.Plan(40*time.Minute, clock.Now().Add(2*time.Hour)) // charge efficiency does not allow to test with 1h
plan := p.Plan(40*time.Minute, 0, clock.Now().Add(2*time.Hour)) // charge efficiency does not allow to test with 1h
assert.False(t, !SlotAt(clock.Now(), plan).IsZero(), "should not start if car can be charged completely after known prices ")
plan = p.Plan(2*time.Hour, clock.Now().Add(2*time.Hour))
plan = p.Plan(2*time.Hour, 0, clock.Now().Add(2*time.Hour))
assert.True(t, !SlotAt(clock.Now(), plan).IsZero(), "should start if car can not be charged completely after known prices ")
}
@ -257,13 +257,64 @@ func TestChargeAfterTargetTime(t *testing.T) {
},
}
plan := p.Plan(time.Hour, clock.Now())
plan := p.Plan(time.Hour, 0, clock.Now())
assert.Equal(t, simplePlan, plan, "expected simple plan")
plan = p.Plan(time.Hour, clock.Now().Add(-time.Hour))
plan = p.Plan(time.Hour, 0, clock.Now().Add(-time.Hour))
assert.Equal(t, simplePlan, plan, "expected simple plan")
}
func TestPrecondition(t *testing.T) {
clock := clock.NewMock()
ctrl := gomock.NewController(t)
trf := api.NewMockTariff(ctrl)
trf.EXPECT().Rates().AnyTimes().Return(rates([]float64{0, 1, 2, 3}, clock.Now(), time.Hour), nil)
p := &Planner{
log: util.NewLogger("foo"),
clock: clock,
tariff: trf,
}
plan := p.Plan(time.Hour, time.Hour, clock.Now().Add(4*time.Hour))
assert.Equal(t, api.Rates{
{
Start: clock.Now().Add(3 * time.Hour),
End: clock.Now().Add(4 * time.Hour),
Value: 3,
},
}, plan, "expected last slot")
plan = p.Plan(2*time.Hour, time.Hour, clock.Now().Add(4*time.Hour))
assert.Equal(t, api.Rates{
{
Start: clock.Now(),
End: clock.Now().Add(1 * time.Hour),
Value: 0,
},
{
Start: clock.Now().Add(3 * time.Hour),
End: clock.Now().Add(4 * time.Hour),
Value: 3,
},
}, plan, "expected two slots")
plan = p.Plan(time.Hour, 30*time.Minute, clock.Now().Add(4*time.Hour))
assert.Equal(t, api.Rates{
{
Start: clock.Now().Add(30 * time.Minute),
End: clock.Now().Add(time.Hour),
Value: 0,
},
{
Start: clock.Now().Add(210 * time.Minute),
End: clock.Now().Add(4 * time.Hour),
Value: 3,
},
}, plan, "expected short early and split late slot")
}
func TestContinuousPlanNoTariff(t *testing.T) {
clock := clock.NewMock()
@ -272,7 +323,7 @@ func TestContinuousPlanNoTariff(t *testing.T) {
clock: clock,
}
plan := p.Plan(time.Hour, clock.Now())
plan := p.Plan(time.Hour, 0, clock.Now())
// single-slot plan
assert.Len(t, plan, 1)
@ -293,7 +344,7 @@ func TestContinuousPlan(t *testing.T) {
tariff: trf,
}
plan := p.Plan(150*time.Minute, clock.Now())
plan := p.Plan(150*time.Minute, 0, clock.Now())
// 3-slot plan
assert.Len(t, plan, 3)
@ -312,7 +363,7 @@ func TestContinuousPlanOutsideRates(t *testing.T) {
tariff: trf,
}
plan := p.Plan(30*time.Minute, clock.Now())
plan := p.Plan(30*time.Minute, 0, clock.Now())
// 3-slot plan
assert.Len(t, plan, 1)

View file

@ -99,14 +99,16 @@ func (site *Site) publishTariffs(greenShareHome float64, greenShareLoadpoints fl
}
fc := struct {
Co2 api.Rates `json:"co2,omitempty"`
FeedIn api.Rates `json:"feedin,omitempty"`
Grid api.Rates `json:"grid,omitempty"`
Solar *solarDetails `json:"solar,omitempty"`
Co2 api.Rates `json:"co2,omitempty"`
FeedIn api.Rates `json:"feedin,omitempty"`
Grid api.Rates `json:"grid,omitempty"`
Planner api.Rates `json:"planner,omitempty"`
Solar *solarDetails `json:"solar,omitempty"`
}{
Co2: tariff.Forecast(site.GetTariff(api.TariffUsageCo2)),
FeedIn: tariff.Forecast(site.GetTariff(api.TariffUsageFeedIn)),
Grid: tariff.Forecast(site.GetTariff(api.TariffUsageGrid)),
Co2: tariff.Forecast(site.GetTariff(api.TariffUsageCo2)),
FeedIn: tariff.Forecast(site.GetTariff(api.TariffUsageFeedIn)),
Planner: tariff.Forecast(site.GetTariff(api.TariffUsagePlanner)),
Grid: tariff.Forecast(site.GetTariff(api.TariffUsageGrid)),
}
// calculate adjusted solar forecast

View file

@ -13,8 +13,9 @@ import (
)
type planStruct struct {
Soc int `json:"soc"`
Time time.Time `json:"time"`
Soc int `json:"soc"`
Precondition int64 `json:"precondition"`
Time time.Time `json:"time"`
}
type vehicleStruct struct {
@ -40,8 +41,8 @@ func (site *Site) publishVehicles() {
for _, v := range vv {
var plan *planStruct
if time, soc := v.GetPlanSoc(); !time.IsZero() {
plan = &planStruct{Soc: soc, Time: time}
if time, precondition, soc := v.GetPlanSoc(); !time.IsZero() {
plan = &planStruct{Soc: soc, Precondition: int64(precondition.Seconds()), Time: time}
}
instance := v.Instance()

View file

@ -71,20 +71,24 @@ func (v *adapter) SetLimitSoc(soc int) {
}
// GetPlanSoc returns the charge plan soc
func (v *adapter) GetPlanSoc() (time.Time, int) {
func (v *adapter) GetPlanSoc() (time.Time, time.Duration, int) {
var ts time.Time
if v, err := settings.Time(v.key() + keys.PlanTime); err == nil {
ts = v
}
var precondition time.Duration
if v, err := settings.Int(v.key() + keys.PlanPrecondition); err == nil {
precondition = time.Duration(v) * time.Second
}
var soc int
if v, err := settings.Int(v.key() + keys.PlanSoc); err == nil {
soc = int(v)
}
return ts, soc
return ts, precondition, soc
}
// SetPlanSoc sets the charge plan soc
func (v *adapter) SetPlanSoc(ts time.Time, soc int) error {
func (v *adapter) SetPlanSoc(ts time.Time, precondition time.Duration, soc int) error {
if !ts.IsZero() && ts.Before(time.Now()) {
return errors.New("timestamp is in the past")
}
@ -94,10 +98,11 @@ func (v *adapter) SetPlanSoc(ts time.Time, soc int) error {
ts = time.Time{}
v.log.DEBUG.Printf("delete %s plan", v.name)
} else {
v.log.DEBUG.Printf("set %s plan soc: %d @ %v", v.name, soc, ts.Round(time.Second).Local())
v.log.DEBUG.Printf("set %s plan soc: %d @ %v (precondition: %v)", v.name, soc, ts.Round(time.Second).Local(), precondition)
}
settings.SetTime(v.key()+keys.PlanTime, ts)
settings.SetInt(v.key()+keys.PlanPrecondition, int64(precondition.Seconds()))
settings.SetInt(v.key()+keys.PlanSoc, int64(soc))
v.publish()

View file

@ -39,9 +39,9 @@ type API interface {
SetLimitSoc(soc int)
// GetPlanSoc returns the charge plan soc
GetPlanSoc() (time.Time, int)
GetPlanSoc() (time.Time, time.Duration, int)
// SetPlanSoc sets the charge plan time and soc
SetPlanSoc(time.Time, int) error
SetPlanSoc(time.Time, time.Duration, int) error
// GetRepeatingPlans returns every repeating plan
GetRepeatingPlans() []api.RepeatingPlanStruct

View file

@ -39,12 +39,12 @@ func (v *dummy) SetLimitSoc(soc int) {
}
// GetPlanSoc returns the charge plan soc
func (v *dummy) GetPlanSoc() (time.Time, int) {
return time.Time{}, 0
func (v *dummy) GetPlanSoc() (time.Time, time.Duration, int) {
return time.Time{}, 0, 0
}
// SetPlanSoc sets the charge plan soc
func (v *dummy) SetPlanSoc(ts time.Time, soc int) error {
func (v *dummy) SetPlanSoc(ts time.Time, precondition time.Duration, soc int) error {
return nil
}

View file

@ -70,12 +70,13 @@ func (mr *MockAPIMockRecorder) GetMinSoc() *gomock.Call {
}
// GetPlanSoc mocks base method.
func (m *MockAPI) GetPlanSoc() (time.Time, int) {
func (m *MockAPI) GetPlanSoc() (time.Time, time.Duration, int) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "GetPlanSoc")
ret0, _ := ret[0].(time.Time)
ret1, _ := ret[1].(int)
return ret0, ret1
ret1, _ := ret[1].(time.Duration)
ret2, _ := ret[2].(int)
return ret0, ret1, ret2
}
// GetPlanSoc indicates an expected call of GetPlanSoc.
@ -151,17 +152,17 @@ func (mr *MockAPIMockRecorder) SetMinSoc(soc any) *gomock.Call {
}
// SetPlanSoc mocks base method.
func (m *MockAPI) SetPlanSoc(arg0 time.Time, arg1 int) error {
func (m *MockAPI) SetPlanSoc(arg0 time.Time, arg1 time.Duration, arg2 int) error {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "SetPlanSoc", arg0, arg1)
ret := m.ctrl.Call(m, "SetPlanSoc", arg0, arg1, arg2)
ret0, _ := ret[0].(error)
return ret0
}
// SetPlanSoc indicates an expected call of SetPlanSoc.
func (mr *MockAPIMockRecorder) SetPlanSoc(arg0, arg1 any) *gomock.Call {
func (mr *MockAPIMockRecorder) SetPlanSoc(arg0, arg1, arg2 any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SetPlanSoc", reflect.TypeOf((*MockAPI)(nil).SetPlanSoc), arg0, arg1)
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SetPlanSoc", reflect.TypeOf((*MockAPI)(nil).SetPlanSoc), arg0, arg1, arg2)
}
// SetRepeatingPlans mocks base method.

View file

@ -592,6 +592,11 @@ goal = "Ladeziel"
modalTitle = "Ladeplanung"
none = "keiner"
planNumber = "Plan {number}"
preconditionDescription = "Lade {duration} vor Abfahrt zur Batterie-Vorkonditionierung."
preconditionLong = "Spätes Laden"
preconditionOptionAll = "alles"
preconditionOptionNo = "nein"
preconditionShort = "Spät"
remove = "Entfernen"
repeating = "wiederholend"
repeatingPlans = "Wiederholende Pläne"

View file

@ -591,6 +591,11 @@ goal = "Charging goal"
modalTitle = "Charging Plan"
none = "none"
planNumber = "Plan {number}"
preconditionDescription = "Charge {duration} before departure for battery preconditioning."
preconditionLong = "Late Charging"
preconditionOptionAll = "everything"
preconditionOptionNo = "no"
preconditionShort = "Late"
remove = "Remove"
repeating = "repeating"
repeatingPlans = "Repeating plans"

View file

@ -81,7 +81,7 @@ func (h *Hub) apply(ev Event, tmpl string) (string, error) {
if v, err := h.vehicles.ByName(name); err == nil {
attr["vehicleLimitSoc"] = v.GetLimitSoc()
attr["vehicleMinSoc"] = v.GetMinSoc()
attr["vehiclePlanTime"], attr["vehiclePlanSoc"] = v.GetPlanSoc()
attr["vehiclePlanTime"], _, attr["vehiclePlanSoc"] = v.GetPlanSoc()
instance := v.Instance()
attr["vehicleTitle"] = instance.Title()

View file

@ -9,6 +9,7 @@ import (
"slices"
"strconv"
"strings"
"time"
)
// pass converts a simple api without return value to api with nil error return value
@ -28,6 +29,21 @@ func parseFloat(payload string) (float64, error) {
return f, err
}
// parseDuration parses a duration string as seconds
func parseDuration(payload string) (time.Duration, error) {
if payload == "" {
return 0, nil
}
v, err := strconv.Atoi(payload)
if err != nil {
return 0, err
}
if v < 0 {
return 0, fmt.Errorf("invalid duration: %s", payload)
}
return time.Duration(v) * time.Second, err
}
// jsonDecoder returns a json decoder with disallowed unknown fields
func jsonDecoder(r io.Reader) *json.Decoder {
dec := json.NewDecoder(r)

View file

@ -27,7 +27,7 @@ func getLoadpointStaticConfig(lp loadpoint.API) loadpoint.StaticConfig {
}
func getLoadpointDynamicConfig(lp loadpoint.API) loadpoint.DynamicConfig {
planTime, planEnergy := lp.GetPlanEnergy()
planTime, planPrecondition, planEnergy := lp.GetPlanEnergy()
return loadpoint.DynamicConfig{
Title: lp.GetTitle(),
DefaultMode: string(lp.GetDefaultMode()),
@ -40,6 +40,7 @@ func getLoadpointDynamicConfig(lp loadpoint.API) loadpoint.DynamicConfig {
Soc: lp.GetSocConfig(),
PlanEnergy: planEnergy,
PlanTime: planTime,
PlanPrecondition: int64(planPrecondition.Seconds()),
LimitEnergy: lp.GetLimitEnergy(),
LimitSoc: lp.GetLimitSoc(),
}

View file

@ -49,21 +49,24 @@ func planHandler(lp loadpoint.API) http.HandlerFunc {
id := lp.EffectivePlanId()
goal, _ := lp.GetPlanGoal()
precondition := lp.GetPlanPreCondDuration()
requiredDuration := lp.GetPlanRequiredDuration(goal, maxPower)
plan := lp.GetPlan(planTime, requiredDuration)
plan := lp.GetPlan(planTime, requiredDuration, precondition)
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 int `json:"planId"`
PlanTime time.Time `json:"planTime"`
Duration int64 `json:"duration"`
Precondition int64 `json:"precondition"`
Plan api.Rates `json:"plan"`
Power float64 `json:"power"`
}{
PlanId: id,
PlanTime: planTime,
Duration: int64(requiredDuration.Seconds()),
Plan: plan,
Power: maxPower,
PlanId: id,
PlanTime: planTime,
Duration: int64(requiredDuration.Seconds()),
Precondition: int64(precondition.Seconds()),
Plan: plan,
Power: maxPower,
}
jsonResult(w, res)
@ -74,6 +77,7 @@ func planHandler(lp loadpoint.API) http.HandlerFunc {
func staticPlanPreviewHandler(lp loadpoint.API) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
query := r.URL.Query()
planTime, err := time.ParseInLocation(time.RFC3339, vars["time"], nil)
if err != nil {
@ -87,6 +91,12 @@ func staticPlanPreviewHandler(lp loadpoint.API) http.HandlerFunc {
return
}
precondition, err := parseDuration(query.Get("precondition"))
if err != nil {
jsonError(w, http.StatusBadRequest, err)
return
}
switch typ := vars["type"]; typ {
case "soc":
if !lp.SocBasedPlanning() {
@ -105,18 +115,20 @@ func staticPlanPreviewHandler(lp loadpoint.API) http.HandlerFunc {
maxPower := lp.EffectiveMaxPower()
requiredDuration := lp.GetPlanRequiredDuration(goal, maxPower)
plan := lp.GetPlan(planTime, requiredDuration)
plan := lp.GetPlan(planTime, requiredDuration, precondition)
res := struct {
PlanTime time.Time `json:"planTime"`
Duration int64 `json:"duration"`
Plan api.Rates `json:"plan"`
Power float64 `json:"power"`
PlanTime time.Time `json:"planTime"`
Duration int64 `json:"duration"`
Precondition int64 `json:"precondition"`
Plan api.Rates `json:"plan"`
Power float64 `json:"power"`
}{
PlanTime: planTime,
Duration: int64(requiredDuration.Seconds()),
Plan: plan,
Power: maxPower,
PlanTime: planTime,
Duration: int64(requiredDuration.Seconds()),
Precondition: int64(precondition.Seconds()),
Plan: plan,
Power: maxPower,
}
jsonResult(w, res)
@ -126,6 +138,7 @@ func staticPlanPreviewHandler(lp loadpoint.API) http.HandlerFunc {
func repeatingPlanPreviewHandler(lp loadpoint.API) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
query := r.URL.Query()
hourMinute := vars["time"]
tz := vars["tz"]
@ -152,20 +165,28 @@ func repeatingPlanPreviewHandler(lp loadpoint.API) http.HandlerFunc {
return
}
precondition, err := parseDuration(query.Get("precondition"))
if err != nil {
jsonError(w, http.StatusBadRequest, err)
return
}
maxPower := lp.EffectiveMaxPower()
requiredDuration := lp.GetPlanRequiredDuration(soc, maxPower)
plan := lp.GetPlan(planTime, requiredDuration)
plan := lp.GetPlan(planTime, requiredDuration, precondition)
res := struct {
PlanTime time.Time `json:"planTime"`
Duration int64 `json:"duration"`
Plan api.Rates `json:"plan"`
Power float64 `json:"power"`
PlanTime time.Time `json:"planTime"`
Duration int64 `json:"duration"`
Precondition int64 `json:"precondition"`
Plan api.Rates `json:"plan"`
Power float64 `json:"power"`
}{
PlanTime: planTime,
Duration: int64(requiredDuration.Seconds()),
Plan: plan,
Power: maxPower,
PlanTime: planTime,
Duration: int64(requiredDuration.Seconds()),
Precondition: int64(precondition.Seconds()),
Plan: plan,
Power: maxPower,
}
jsonResult(w, res)
@ -176,6 +197,7 @@ func repeatingPlanPreviewHandler(lp loadpoint.API) http.HandlerFunc {
func planEnergyHandler(lp loadpoint.API) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
query := r.URL.Query()
ts, err := time.ParseInLocation(time.RFC3339, vars["time"], nil)
if err != nil {
@ -189,19 +211,27 @@ func planEnergyHandler(lp loadpoint.API) http.HandlerFunc {
return
}
if err := lp.SetPlanEnergy(ts, val); err != nil {
precondition, err := parseDuration(query.Get("precondition"))
if err != nil {
jsonError(w, http.StatusBadRequest, err)
return
}
ts, energy := lp.GetPlanEnergy()
if err := lp.SetPlanEnergy(ts, precondition, val); err != nil {
jsonError(w, http.StatusBadRequest, err)
return
}
ts, precondition, energy := lp.GetPlanEnergy()
res := struct {
Energy float64 `json:"energy"`
Time time.Time `json:"time"`
Energy float64 `json:"energy"`
Precondition int64 `json:"precondition"`
Time time.Time `json:"time"`
}{
Energy: energy,
Time: ts,
Energy: energy,
Precondition: int64(precondition.Seconds()),
Time: ts,
}
jsonResult(w, res)
@ -211,7 +241,7 @@ func planEnergyHandler(lp loadpoint.API) http.HandlerFunc {
// planRemoveHandler removes plan time
func planRemoveHandler(lp loadpoint.API) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if err := lp.SetPlanEnergy(time.Time{}, 0); err != nil {
if err := lp.SetPlanEnergy(time.Time{}, 0, 0); err != nil {
jsonError(w, http.StatusBadRequest, err)
return
}

View file

@ -73,6 +73,7 @@ func limitSocHandler(site site.API) http.HandlerFunc {
func planSocHandler(site site.API) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
query := r.URL.Query()
v, err := site.Vehicles().ByName(vars["name"])
if err != nil {
@ -92,19 +93,27 @@ func planSocHandler(site site.API) http.HandlerFunc {
return
}
if err := v.SetPlanSoc(ts, soc); err != nil {
precondition, err := parseDuration(query.Get("precondition"))
if err != nil {
jsonError(w, http.StatusBadRequest, err)
return
}
ts, soc = v.GetPlanSoc()
if err := v.SetPlanSoc(ts, precondition, soc); err != nil {
jsonError(w, http.StatusBadRequest, err)
return
}
ts, precondition, soc = v.GetPlanSoc()
res := struct {
Soc int `json:"soc"`
Time time.Time `json:"time"`
Soc int `json:"soc"`
Precondition int64 `json:"precondition"`
Time time.Time `json:"time"`
}{
Soc: soc,
Time: ts,
Soc: soc,
Precondition: int64(precondition.Seconds()),
Time: ts,
}
jsonResult(w, res)
@ -152,7 +161,7 @@ func planSocRemoveHandler(site site.API) http.HandlerFunc {
return
}
if err := v.SetPlanSoc(time.Time{}, 0); err != nil {
if err := v.SetPlanSoc(time.Time{}, 0, 0); err != nil {
jsonError(w, http.StatusBadRequest, err)
return
}

View file

@ -244,12 +244,13 @@ func (m *MQTT) listenLoadpointSetters(topic string, site site.API, lp loadpoint.
{"batteryBoost", boolSetter(lp.SetBatteryBoost)},
{"planEnergy", func(payload string) error {
var plan struct {
Time time.Time `json:"time"`
Value float64 `json:"value"`
Time time.Time `json:"time"`
Precondition int64 `json:"precondition"`
Value float64 `json:"value"`
}
err := json.Unmarshal([]byte(payload), &plan)
if err == nil {
err = lp.SetPlanEnergy(plan.Time, plan.Value)
err = lp.SetPlanEnergy(plan.Time, time.Duration(plan.Precondition)*time.Second, plan.Value)
}
return err
}},
@ -280,12 +281,13 @@ func (m *MQTT) listenVehicleSetters(topic string, v vehicle.API) error {
{"minSoc", intSetter(pass(v.SetMinSoc))},
{"planSoc", func(payload string) error {
var plan struct {
Time time.Time `json:"time"`
Value int `json:"value"`
Time time.Time `json:"time"`
Precondition int64 `json:"precondition"`
Value int `json:"value"`
}
err := json.Unmarshal([]byte(payload), &plan)
if err == nil {
err = v.SetPlanSoc(plan.Time, plan.Value)
err = v.SetPlanSoc(plan.Time, time.Duration(plan.Precondition)*time.Second, plan.Value)
}
return err
}},

View file

@ -1,10 +1,13 @@
import { test, expect } from "@playwright/test";
import { test, expect, devices } from "@playwright/test";
import { start, stop, baseUrl, restart } from "./evcc";
test.use({ baseURL: baseUrl() });
test.describe.configure({ mode: "parallel" });
const mobile = devices["iPhone 12 Mini"].viewport;
const CONFIG = "plan.evcc.yaml";
const CONFIG_NO_TARIFF = "basics.evcc.yaml";
test.beforeEach(async () => {
await start(CONFIG);
@ -71,6 +74,11 @@ test.describe("basic functionality", async () => {
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-precondition-lg-toggle").click();
await page
.getByTestId("static-plan-precondition-lg-select")
.getByRole("combobox")
.selectOption("1 hour");
await page.getByTestId("static-plan-active").click();
await page.getByRole("button", { name: "Close" }).click();
@ -574,6 +582,11 @@ test.describe("repeating", async () => {
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-precondition-lg-toggle").click();
await plan
.getByTestId("repeating-plan-precondition-lg-select")
.getByRole("combobox")
.selectOption("2 hours");
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");
@ -595,5 +608,26 @@ test.describe("repeating", async () => {
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");
await expect(modal.getByTestId("repeating-plan-precondition-lg-toggle")).toBeChecked();
await expect(
modal.getByTestId("repeating-plan-precondition-lg-select").locator("option:checked")
).toHaveText("2 hours");
});
});
// add test for precondition, start with basic.evcc.yaml and verify that precondition toggle element is not visible. make dedicated describe block
test.describe("precondition", async () => {
test("only if dynamic tariff exists", async ({ page }) => {
await restart(CONFIG_NO_TARIFF);
await page.goto("/");
const lp1 = await page.getByTestId("loadpoint").first();
await lp1.getByTestId("charging-plan").getByRole("button", { name: "none" }).click();
await expect(page.getByTestId("static-plan-active")).toBeVisible();
await expect(page.getByTestId("static-plan-precondition-lg-toggle")).not.toBeVisible();
await expect(page.getByTestId("static-plan-precondition-lg-select")).not.toBeVisible();
// verify small viewport
await page.setViewportSize(mobile);
await expect(page.getByTestId("static-plan-precondition-select")).not.toBeVisible();
});
});