Reapply "Planner: add continuous strategy (BC) (#24423)"

This reverts commit 7acc133f3c.
This commit is contained in:
andig 2025-12-31 13:19:49 +01:00 • committed by andig
parent 916de19f4f
commit dc6d056538
68 changed files with 2245 additions and 950 deletions

View file

@ -1,10 +1,17 @@
package api package api
import "time"
type RepeatingPlan struct { type RepeatingPlan struct {
Weekdays []int `json:"weekdays"` // 0-6 (Sunday-Saturday) Weekdays []int `json:"weekdays"` // 0-6 (Sunday-Saturday)
Time string `json:"time"` // HH:MM Time string `json:"time"` // HH:MM
Tz string `json:"tz"` // timezone in IANA format Tz string `json:"tz"` // timezone in IANA format
Soc int `json:"soc"` // target soc Soc int `json:"soc"` // target soc
Precondition int64 `json:"precondition"` // precondition duration in seconds
Active bool `json:"active"` // active flag Active bool `json:"active"` // active flag
Precondition int64 `json:"-" todo:"..."` // TODO deprecated
}
type PlanStrategy struct {
Continuous bool `json:"continuous"` // force continuous planning
Precondition time.Duration `json:"precondition"` // precondition duration in seconds
} }

View file

@ -69,6 +69,7 @@
@static-plan-updated="updateStaticPlan" @static-plan-updated="updateStaticPlan"
@static-plan-removed="removeStaticPlan" @static-plan-removed="removeStaticPlan"
@repeating-plans-updated="updateRepeatingPlans" @repeating-plans-updated="updateRepeatingPlans"
@plan-strategy-updated="updatePlanStrategy"
/> />
<ChargingPlanArrival <ChargingPlanArrival
v-if="arrivalTabActive" v-if="arrivalTabActive"
@ -95,7 +96,13 @@ import api from "@/api";
import { optionStep, fmtEnergy } from "@/utils/energyOptions.ts"; import { optionStep, fmtEnergy } from "@/utils/energyOptions.ts";
import { defineComponent, type PropType } from "vue"; import { defineComponent, type PropType } from "vue";
import type { CURRENCY, Timeout, Vehicle } from "@/types/evcc"; import type { CURRENCY, Timeout, Vehicle } from "@/types/evcc";
import type { StaticPlan, StaticSocPlan, StaticEnergyPlan, RepeatingPlan } from "./types"; import type {
StaticPlan,
StaticSocPlan,
StaticEnergyPlan,
RepeatingPlan,
PlanStrategy,
} from "./types";
import type { Forecast } from "@/types/evcc.ts"; import type { Forecast } from "@/types/evcc.ts";
const ONE_MINUTE = 60 * 1000; const ONE_MINUTE = 60 * 1000;
@ -114,6 +121,8 @@ export default defineComponent({
effectiveLimitSoc: Number, effectiveLimitSoc: Number,
effectivePlanSoc: Number, effectivePlanSoc: Number,
effectivePlanTime: String, effectivePlanTime: String,
effectivePlanPrecondition: Number,
effectivePlanContinuous: Boolean,
id: [String, Number], id: [String, Number],
limitEnergy: Number, limitEnergy: Number,
mode: String, mode: String,
@ -121,7 +130,6 @@ export default defineComponent({
planEnergy: Number, planEnergy: Number,
planTime: String, planTime: String,
planTimeUnreachable: Boolean, planTimeUnreachable: Boolean,
planPrecondition: { type: Number, default: 0 },
planOverrun: Number, planOverrun: Number,
rangePerSoc: Number, rangePerSoc: Number,
smartCostType: String, smartCostType: String,
@ -166,7 +174,6 @@ export default defineComponent({
return { return {
soc: plan.soc, soc: plan.soc,
time: new Date(plan.time), time: new Date(plan.time),
precondition: plan.precondition,
}; };
} }
return null; return null;
@ -175,7 +182,6 @@ export default defineComponent({
return { return {
energy: this.planEnergy, energy: this.planEnergy,
time: new Date(this.planTime), time: new Date(this.planTime),
precondition: this.planPrecondition,
}; };
} }
return null; return null;
@ -186,7 +192,7 @@ export default defineComponent({
this.vehicle.repeatingPlans && this.vehicle.repeatingPlans &&
this.vehicle.repeatingPlans.length > 0 this.vehicle.repeatingPlans.length > 0
) { ) {
return [...this.vehicle.repeatingPlans]; return [...(this.vehicle.repeatingPlans || [])];
} }
return []; return [];
}, },
@ -286,15 +292,12 @@ export default defineComponent({
}, },
updateStaticPlan(plan: StaticPlan): void { updateStaticPlan(plan: StaticPlan): void {
const timeISO = plan.time.toISOString(); const timeISO = plan.time.toISOString();
const params = plan.precondition ? { precondition: plan.precondition } : undefined;
if (this.socBasedPlanning) { if (this.socBasedPlanning) {
const p = plan as StaticSocPlan; const p = plan as StaticSocPlan;
api.post(`${this.apiVehicle}plan/soc/${p.soc}/${timeISO}`, null, { params }); api.post(`${this.apiVehicle}plan/soc/${p.soc}/${timeISO}`, null);
} else { } else {
const p = plan as StaticEnergyPlan; const p = plan as StaticEnergyPlan;
api.post(`${this.apiLoadpoint}plan/energy/${p.energy}/${timeISO}`, null, { api.post(`${this.apiLoadpoint}plan/energy/${p.energy}/${timeISO}`, null);
params,
});
} }
}, },
removeStaticPlan(): void { removeStaticPlan(): void {
@ -307,6 +310,13 @@ export default defineComponent({
updateRepeatingPlans(plans: RepeatingPlan[]): void { updateRepeatingPlans(plans: RepeatingPlan[]): void {
api.post(`${this.apiVehicle}plan/repeating`, plans); api.post(`${this.apiVehicle}plan/repeating`, plans);
}, },
updatePlanStrategy(strategy: PlanStrategy): void {
if (this.socBasedPlanning) {
api.post(`${this.apiVehicle}plan/strategy`, strategy);
} else {
api.post(`${this.apiLoadpoint}plan/strategy`, strategy);
}
},
setMinSoc(soc: number): void { setMinSoc(soc: number): void {
api.post(`${this.apiVehicle}minsoc/${soc}`); api.post(`${this.apiVehicle}minsoc/${soc}`);
}, },

View file

@ -24,17 +24,12 @@
{{ $t("main.chargingPlan.time") }} {{ $t("main.chargingPlan.time") }}
</label> </label>
</div> </div>
<div :class="showPrecondition ? 'col-3' : 'col-4'"> <div class="col-3">
<label :for="formId('goal')"> <label :for="formId('goal')">
{{ $t("main.chargingPlan.goal") }} {{ $t("main.chargingPlan.goal") }}
</label> </label>
</div> </div>
<div v-if="showPrecondition" class="col-1"> <div class="col-2">
<label :for="formId('precondition')">
{{ $t("main.chargingPlan.preconditionShort") }}
</label>
</div>
<div class="col-1">
<label :for="formId('active')"> {{ $t("main.chargingPlan.active") }} </label> <label :for="formId('active')"> {{ $t("main.chargingPlan.active") }} </label>
</div> </div>
</div> </div>
@ -81,7 +76,7 @@
{{ $t("main.chargingPlan.goal") }} {{ $t("main.chargingPlan.goal") }}
</label> </label>
</div> </div>
<div :class="['col-7', showPrecondition ? 'col-lg-3' : 'col-lg-4', 'mb-2', 'mb-lg-0']"> <div class="col-7 col-lg-3 mb-2 mb-lg-0">
<select <select
:id="formId('goal')" :id="formId('goal')"
v-model="selectedSoc" v-model="selectedSoc"
@ -94,21 +89,6 @@
</option> </option>
</select> </select>
</div> </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"> <div class="col-5 d-lg-none col-form-label">
<label :for="formId('active')"> <label :for="formId('active')">
{{ $t("main.chargingPlan.active") }} {{ $t("main.chargingPlan.active") }}
@ -130,7 +110,7 @@
</div> </div>
</div> </div>
<div <div
class="col-4 col-lg-1 d-flex align-items-center justify-content-end justify-content-lg-start" class="col-4 col-lg-2 d-flex align-items-center justify-content-end justify-content-lg-start"
> >
<button <button
v-if="showApply" v-if="showApply"
@ -140,11 +120,7 @@
tabindex="0" tabindex="0"
@click="update(true)" @click="update(true)"
> >
<span class="d-lg-none">{{ $t("main.chargingPlan.update") }}</span> {{ $t("main.chargingPlan.update") }}
<shopicon-regular-checkmark
size="s"
class="flex-shrink-0 d-none d-lg-block"
></shopicon-regular-checkmark>
</button> </button>
<button <button
v-else v-else
@ -158,31 +134,22 @@
</button> </button>
</div> </div>
</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> </div>
</template> </template>
<script lang="ts"> <script lang="ts">
import "@h2d2/shopicons/es/regular/trash"; import "@h2d2/shopicons/es/regular/trash";
import "@h2d2/shopicons/es/regular/checkmark";
import { distanceUnit } from "@/units"; import { distanceUnit } from "@/units";
import MultiSelect from "../Helper/MultiSelect.vue"; import MultiSelect from "../Helper/MultiSelect.vue";
import formatter from "@/mixins/formatter"; import formatter from "@/mixins/formatter";
import deepEqual from "@/utils/deepEqual"; import deepEqual from "@/utils/deepEqual";
import PreconditionSelect from "./PreconditionSelect.vue";
import type { SelectOption } from "@/types/evcc"; import type { SelectOption } from "@/types/evcc";
import { defineComponent, type PropType } from "vue"; import { defineComponent, type PropType } from "vue";
export default defineComponent({ export default defineComponent({
name: "ChargingPlanRepeatingSettings", name: "ChargingPlanRepeatingSettings",
components: { MultiSelect, PreconditionSelect }, components: { MultiSelect },
mixins: [formatter], mixins: [formatter],
props: { props: {
number: Number, number: Number,
@ -190,12 +157,10 @@ export default defineComponent({
time: String, time: String,
tz: String, tz: String,
soc: Number, soc: Number,
precondition: Number,
showHeader: Boolean, showHeader: Boolean,
active: Boolean, active: Boolean,
rangePerSoc: Number, rangePerSoc: Number,
formIdPrefix: String, formIdPrefix: String,
showPrecondition: Boolean,
}, },
emits: ["updated", "removed"], emits: ["updated", "removed"],
data() { data() {
@ -204,7 +169,6 @@ export default defineComponent({
selectedTime: this.time, selectedTime: this.time,
selectedSoc: this.soc, selectedSoc: this.soc,
selectedActive: this.active, selectedActive: this.active,
selectedPrecondition: this.precondition,
}; };
}, },
computed: { computed: {
@ -213,8 +177,7 @@ export default defineComponent({
!deepEqual(this.weekdays, this.selectedWeekdays) || !deepEqual(this.weekdays, this.selectedWeekdays) ||
this.time !== this.selectedTime || this.time !== this.selectedTime ||
this.soc !== this.selectedSoc || this.soc !== this.selectedSoc ||
this.active !== this.selectedActive || this.active !== this.selectedActive
this.precondition !== this.selectedPrecondition
); );
}, },
showApply(): boolean { showApply(): boolean {
@ -248,9 +211,6 @@ export default defineComponent({
active(newValue: boolean) { active(newValue: boolean) {
this.selectedActive = newValue; this.selectedActive = newValue;
}, },
precondition(newValue: number) {
this.selectedPrecondition = newValue;
},
}, },
methods: { methods: {
id(): number { id(): number {
@ -274,7 +234,6 @@ export default defineComponent({
soc: this.selectedSoc, soc: this.selectedSoc,
tz: this.tz, tz: this.tz,
active: this.selectedActive, active: this.selectedActive,
precondition: this.selectedPrecondition,
}; };
if (forceSave || !this.selectedActive) { if (forceSave || !this.selectedActive) {

View file

@ -22,17 +22,12 @@
{{ $t("main.chargingPlan.time") }} {{ $t("main.chargingPlan.time") }}
</label> </label>
</div> </div>
<div :class="showPrecondition ? 'col-3' : 'col-4'"> <div class="col-3">
<label :for="formId('goal')"> <label :for="formId('goal')">
{{ $t("main.chargingPlan.goal") }} {{ $t("main.chargingPlan.goal") }}
</label> </label>
</div> </div>
<div v-if="showPrecondition" class="col-1"> <div class="col-2">
<label :for="formId('precondition')">
{{ $t("main.chargingPlan.preconditionShort") }}
</label>
</div>
<div class="col-1">
<label :for="formId('active')"> {{ $t("main.chargingPlan.active") }} </label> <label :for="formId('active')"> {{ $t("main.chargingPlan.active") }} </label>
</div> </div>
</div> </div>
@ -83,7 +78,7 @@
{{ $t("main.chargingPlan.goal") }} {{ $t("main.chargingPlan.goal") }}
</label> </label>
</div> </div>
<div :class="['col-7', showPrecondition ? 'col-lg-3' : 'col-lg-4', 'mb-2', 'mb-lg-0']"> <div class="col-7 col-lg-3 mb-2 mb-lg-0">
<select <select
v-if="socBasedPlanning" v-if="socBasedPlanning"
:id="formId('goal')" :id="formId('goal')"
@ -109,21 +104,6 @@
</option> </option>
</select> </select>
</div> </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"> <div class="col-5 d-lg-none col-form-label">
<label :for="formId('active')"> <label :for="formId('active')">
{{ $t("main.chargingPlan.active") }} {{ $t("main.chargingPlan.active") }}
@ -145,7 +125,7 @@
</div> </div>
</div> </div>
<div <div
class="col-4 col-lg-1 d-flex align-items-center justify-content-end justify-content-lg-start" class="col-4 col-lg-2 d-flex align-items-center justify-content-end justify-content-lg-start"
> >
<button <button
v-if="dataChanged && !isNew" v-if="dataChanged && !isNew"
@ -154,26 +134,12 @@
data-testid="static-plan-apply" data-testid="static-plan-apply"
:disabled="timeInThePast" :disabled="timeInThePast"
tabindex="0" tabindex="0"
:aria-label="$t('main.chargingPlan.update')"
@click="update" @click="update"
> >
<span class="d-lg-none">{{ $t("main.chargingPlan.update") }}</span> {{ $t("main.chargingPlan.update") }}
<shopicon-regular-checkmark
size="s"
class="flex-shrink-0 d-none d-lg-block"
></shopicon-regular-checkmark>
</button> </button>
</div> </div>
</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"> <p class="mb-0" data-testid="plan-entry-warnings">
<span v-if="timeInThePast" class="d-block text-danger my-2"> <span v-if="timeInThePast" class="d-block text-danger my-2">
{{ $t("main.targetCharge.targetIsInThePast") }} {{ $t("main.targetCharge.targetIsInThePast") }}
@ -183,13 +149,11 @@
</template> </template>
<script lang="ts"> <script lang="ts">
import "@h2d2/shopicons/es/regular/checkmark";
import { distanceUnit } from "@/units"; import { distanceUnit } from "@/units";
import formatter from "@/mixins/formatter"; import formatter from "@/mixins/formatter";
import { energyOptions } from "@/utils/energyOptions.ts"; import { energyOptions } from "@/utils/energyOptions.ts";
import { defineComponent } from "vue"; import { defineComponent } from "vue";
import PreconditionSelect from "./PreconditionSelect.vue";
const LAST_TARGET_TIME_KEY = "last_target_time"; const LAST_TARGET_TIME_KEY = "last_target_time";
const LAST_SOC_GOAL_KEY = "last_soc_goal"; const LAST_SOC_GOAL_KEY = "last_soc_goal";
@ -198,7 +162,6 @@ const DEFAULT_TARGET_TIME = "7:00";
export default defineComponent({ export default defineComponent({
name: "ChargingPlanStaticSettings", name: "ChargingPlanStaticSettings",
components: { PreconditionSelect },
mixins: [formatter], mixins: [formatter],
props: { props: {
id: [String, Number], id: [String, Number],
@ -210,8 +173,6 @@ export default defineComponent({
capacity: Number, capacity: Number,
socBasedPlanning: Boolean, socBasedPlanning: Boolean,
multiplePlans: Boolean, multiplePlans: Boolean,
precondition: Number,
showPrecondition: Boolean,
}, },
emits: ["static-plan-updated", "static-plan-removed", "plan-preview"], emits: ["static-plan-updated", "static-plan-removed", "plan-preview"],
data() { data() {
@ -221,7 +182,6 @@ export default defineComponent({
selectedSoc: this.soc, selectedSoc: this.soc,
selectedEnergy: this.energy, selectedEnergy: this.energy,
active: false, active: false,
selectedPrecondition: this.precondition,
}; };
}, },
computed: { computed: {
@ -265,7 +225,6 @@ export default defineComponent({
energy: this.energy, energy: this.energy,
day: this.fmtDayString(t), day: this.fmtDayString(t),
time: this.fmtTimeString(t), time: this.fmtTimeString(t),
precondition: this.precondition,
}; };
}, },
dataChanged() { dataChanged() {
@ -275,8 +234,7 @@ export default defineComponent({
const goalChanged = this.socBasedPlanning const goalChanged = this.socBasedPlanning
? this.originalData.soc != this.selectedSoc ? this.originalData.soc != this.selectedSoc
: this.originalData.energy != this.selectedEnergy; : this.originalData.energy != this.selectedEnergy;
const preconditionChanged = this.originalData.precondition != this.selectedPrecondition; return dateChanged || goalChanged;
return dateChanged || goalChanged || preconditionChanged;
}, },
isNew() { isNew() {
return !this.time && (!this.soc || !this.energy); return !this.time && (!this.soc || !this.energy);
@ -303,12 +261,6 @@ export default defineComponent({
isNew(value) { isNew(value) {
this.active = !value; this.active = !value;
}, },
precondition(value) {
this.selectedPrecondition = value;
},
selectedPrecondition() {
this.preview();
},
}, },
mounted() { mounted() {
this.initInputFields(); this.initInputFields();
@ -383,7 +335,6 @@ export default defineComponent({
time: this.selectedDate, time: this.selectedDate,
soc: this.selectedSoc, soc: this.selectedSoc,
energy: this.selectedEnergy, energy: this.selectedEnergy,
precondition: this.selectedPrecondition,
}); });
}, },
preview(force = false) { preview(force = false) {
@ -394,7 +345,6 @@ export default defineComponent({
time: this.selectedDate, time: this.selectedDate,
soc: this.selectedSoc, soc: this.selectedSoc,
energy: this.selectedEnergy, energy: this.selectedEnergy,
precondition: this.selectedPrecondition,
}); });
}, },
toggle(e: Event) { toggle(e: Event) {

View file

@ -0,0 +1,167 @@
<template>
<div class="strategy-wrapper" :class="{ open: show }">
<div class="strategy-content">
<div class="row">
<div class="col-12 col-sm-6 col-lg-3 offset-lg-3 mb-3">
<div class="row">
<label :for="formId('continuous')" class="col-form-label col-5 col-sm-12">
{{ $t("main.chargingPlan.optimization.label") }}
</label>
<div class="col-7 col-sm-12">
<select
:id="formId('continuous')"
v-model="localContinuous"
class="form-select"
@change="updateStrategy"
>
<option :value="false">
{{ $t("main.chargingPlan.optimization.cheapest") }}
</option>
<option :value="true">
{{ $t("main.chargingPlan.optimization.continuous") }}
</option>
</select>
</div>
</div>
</div>
<div class="col-sm-6 col-lg-3 mb-3">
<div class="row">
<label :for="formId('precondition')" class="col-form-label col-5 col-sm-12">
{{ $t("main.chargingPlan.precondition.label") }}
</label>
<div class="col-7 col-sm-12">
<select
:id="formId('precondition')"
v-model="localPrecondition"
class="form-select"
@change="updateStrategy"
>
<option :value="0">
{{ $t("main.chargingPlan.precondition.optionNo") }}
</option>
<option
v-for="opt in preconditionOptions"
:key="opt.value"
:value="opt.value"
>
{{ opt.name }}
</option>
</select>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script lang="ts">
import { defineComponent } from "vue";
import formatter from "@/mixins/formatter";
import type { PlanStrategy } from "./types";
export default defineComponent({
name: "ChargingPlanStrategy",
mixins: [formatter],
props: {
id: [String, Number],
show: Boolean,
precondition: { type: Number, default: 0 },
continuous: { type: Boolean, default: false },
},
emits: ["update"],
data() {
return {
localPrecondition: this.precondition,
localContinuous: this.continuous,
};
},
computed: {
preconditionOptions() {
const HOUR = 60 * 60;
const QUARTER_HOUR = 0.25 * HOUR;
const HALF_HOUR = 0.5 * HOUR;
const ONE_HOUR = 1 * HOUR;
const TWO_HOURS = 2 * HOUR;
const EVERYTHING = 7 * 24 * HOUR;
const options = [QUARTER_HOUR, HALF_HOUR, ONE_HOUR, TWO_HOURS, EVERYTHING];
// support custom values (via API)
if (this.localPrecondition && !options.includes(this.localPrecondition)) {
options.push(this.localPrecondition);
}
return options.map((s) => ({
value: s,
name:
s === EVERYTHING
? this.$t("main.chargingPlan.precondition.optionAll")
: this.fmtDurationLong(s),
}));
},
},
watch: {
precondition: {
handler(newValue: number) {
// Only update if value actually changed from external source
if (newValue !== this.localPrecondition) {
this.localPrecondition = newValue;
}
},
immediate: true,
},
continuous: {
handler(newValue: boolean) {
// Only update if value actually changed from external source
if (newValue !== this.localContinuous) {
this.localContinuous = newValue;
}
},
immediate: true,
},
},
methods: {
formId(name: string) {
return `chargingplan-${this.id}-${name}`;
},
updateStrategy(): void {
const strategy: PlanStrategy = {
continuous: this.localContinuous,
precondition: this.localPrecondition,
};
this.$emit("update", strategy);
},
},
});
</script>
<style scoped>
.strategy-wrapper {
display: grid;
grid-template-rows: 0fr;
margin-bottom: 0;
transition:
grid-template-rows 0.3s ease 0.2s,
margin-bottom 0.3s ease 0.2s;
}
.strategy-wrapper.open {
grid-template-rows: 1fr;
margin-bottom: 1rem;
transition:
grid-template-rows 0.3s ease,
margin-bottom 0.3s ease;
}
.strategy-content {
overflow: hidden;
opacity: 0;
transition: opacity 0.2s ease;
}
.open .strategy-content {
opacity: 1;
transition: opacity 0.3s ease 0.2s;
}
</style>

View file

@ -8,7 +8,6 @@
:formIdPrefix="formIdPrefix" :formIdPrefix="formIdPrefix"
v-bind="plan" v-bind="plan"
:rangePerSoc="rangePerSoc" :rangePerSoc="rangePerSoc"
:showPrecondition="showPrecondition"
@updated="updatePlan(index, $event)" @updated="updatePlan(index, $event)"
@removed="removePlan(index)" @removed="removePlan(index)"
/> />
@ -49,7 +48,6 @@ export default defineComponent({
id: [Number, String], id: [Number, String],
rangePerSoc: Number, rangePerSoc: Number,
plans: { type: Array as PropType<RepeatingPlan[]>, default: () => [] }, plans: { type: Array as PropType<RepeatingPlan[]>, default: () => [] },
showPrecondition: Boolean,
}, },
emits: ["updated"], emits: ["updated"],
computed: { computed: {

View file

@ -11,9 +11,8 @@
:soc-per-kwh="socPerKwh" :soc-per-kwh="socPerKwh"
:soc-based-planning="socBasedPlanning" :soc-based-planning="socBasedPlanning"
:multiple-plans="multiplePlans" :multiple-plans="multiplePlans"
:show-precondition="showPrecondition" @static-plan-updated="updateStaticPlan"
@static-plan-updated="(data) => updateStaticPlan({ index: 0, ...data })" @static-plan-removed="removeStaticPlan"
@static-plan-removed="() => removeStaticPlan(0)"
@plan-preview="previewStaticPlan" @plan-preview="previewStaticPlan"
/> />
<div v-if="socBasedPlanning"> <div v-if="socBasedPlanning">
@ -30,7 +29,6 @@
:id="id" :id="id"
:rangePerSoc="rangePerSoc" :rangePerSoc="rangePerSoc"
:plans="repeatingPlans" :plans="repeatingPlans"
:show-precondition="showPrecondition"
@updated="updateRepeatingPlans" @updated="updateRepeatingPlans"
/> />
</div> </div>
@ -38,29 +36,32 @@
</div> </div>
<hr /> <hr />
<h5> <h5>
<div class="inner" data-testid="plan-preview-title"> <div class="inner d-flex align-items-center gap-1" data-testid="plan-preview-title">
<span v-if="!multiplePlans"> <span v-if="!multiplePlans">
{{ $t(`main.targetCharge.${noActivePlan ? "preview" : "currentPlan"}`) }} {{ $t(`main.targetCharge.${noActivePlan ? "preview" : "currentPlan"}`) }}
</span> </span>
<CustomSelect <span v-else-if="noActivePlan">{{ $t("main.targetCharge.preview") }} #1</span>
v-else-if="noActivePlan" <span v-else-if="alreadyReached">{{ $t("main.targetCharge.goalReached") }}</span>
:options="previewPlanOptions" <span v-else>{{ nextPlanTitle }}</span>
:selected="selectedPreviewId" <button
data-testid="preview-plan-select" v-if="showStrategy"
@change="selectPreviewPlan($event.target.value)" type="button"
class="btn btn-sm"
:class="strategyOpen ? 'btn-secondary' : 'evcc-gray'"
:aria-label="$t('main.chargingPlan.strategySettings')"
tabindex="0"
@click="strategyOpen = !strategyOpen"
> >
<span class="text-decoration-underline"> <shopicon-regular-adjust size="s"></shopicon-regular-adjust>
{{ selectedPreviewPlanTitle }} </button>
</span>
</CustomSelect>
<span v-else-if="alreadyReached">
{{ $t("main.targetCharge.goalReached") }}
</span>
<span v-else>
{{ nextPlanTitle }}
</span>
</div> </div>
</h5> </h5>
<ChargingPlanStrategy
v-if="showStrategy"
v-bind="chargingPlanStrategyProps"
:show="strategyOpen"
@update="updatePlanStrategy"
/>
<ChargingPlanPreview v-bind="chargingPlanPreviewProps" /> <ChargingPlanPreview v-bind="chargingPlanPreviewProps" />
<ChargingPlanWarnings v-bind="chargingPlanWarningsProps" /> <ChargingPlanWarnings v-bind="chargingPlanWarningsProps" />
</div> </div>
@ -70,16 +71,16 @@
import "@h2d2/shopicons/es/regular/plus"; import "@h2d2/shopicons/es/regular/plus";
import Preview from "./Preview.vue"; import Preview from "./Preview.vue";
import PlanStaticSettings from "./PlanStaticSettings.vue"; import PlanStaticSettings from "./PlanStaticSettings.vue";
import ChargingPlanStrategy from "./PlanStrategy.vue";
import RepeatingSettings from "./PlansRepeatingSettings.vue"; import RepeatingSettings from "./PlansRepeatingSettings.vue";
import Warnings from "./Warnings.vue"; import Warnings from "./Warnings.vue";
import formatter from "@/mixins/formatter"; import formatter from "@/mixins/formatter";
import collector from "@/mixins/collector"; import collector from "@/mixins/collector";
import api from "@/api"; import api from "@/api";
import CustomSelect from "../Helper/CustomSelect.vue";
import deepEqual from "@/utils/deepEqual"; import deepEqual from "@/utils/deepEqual";
import convertRates from "@/utils/convertRates"; import convertRates from "@/utils/convertRates";
import { defineComponent, type PropType } from "vue"; import { defineComponent, type PropType } from "vue";
import type { Vehicle, PartialBy, Timeout, SelectOption, CURRENCY, Forecast } from "@/types/evcc"; import type { Vehicle, Timeout, CURRENCY, Forecast } from "@/types/evcc";
import type { import type {
StaticPlan, StaticPlan,
RepeatingPlan, RepeatingPlan,
@ -87,6 +88,7 @@ import type {
StaticSocPlan, StaticSocPlan,
StaticEnergyPlan, StaticEnergyPlan,
PlanResponse, PlanResponse,
PlanStrategy,
} from "./types"; } from "./types";
export default defineComponent({ export default defineComponent({
@ -94,9 +96,9 @@ export default defineComponent({
components: { components: {
ChargingPlanPreview: Preview, ChargingPlanPreview: Preview,
ChargingPlanStaticSettings: PlanStaticSettings, ChargingPlanStaticSettings: PlanStaticSettings,
ChargingPlanStrategy,
ChargingPlansRepeatingSettings: RepeatingSettings, ChargingPlansRepeatingSettings: RepeatingSettings,
ChargingPlanWarnings: Warnings, ChargingPlanWarnings: Warnings,
CustomSelect,
}, },
mixins: [formatter, collector], mixins: [formatter, collector],
props: { props: {
@ -106,6 +108,8 @@ export default defineComponent({
effectiveLimitSoc: Number, effectiveLimitSoc: Number,
effectivePlanTime: String, effectivePlanTime: String,
effectivePlanSoc: Number, effectivePlanSoc: Number,
effectivePlanPrecondition: Number,
effectivePlanContinuous: Boolean,
planEnergy: Number, planEnergy: Number,
limitEnergy: Number, limitEnergy: Number,
socBasedPlanning: Boolean, socBasedPlanning: Boolean,
@ -120,15 +124,20 @@ export default defineComponent({
planOverrun: Number, planOverrun: Number,
forecast: Object as PropType<Forecast>, forecast: Object as PropType<Forecast>,
}, },
emits: ["static-plan-removed", "static-plan-updated", "repeating-plans-updated"], emits: [
"static-plan-removed",
"static-plan-updated",
"repeating-plans-updated",
"plan-strategy-updated",
],
data() { data() {
return { return {
staticPlanPreview: {} as StaticPlan, staticPlanPreview: {} as StaticPlan,
plan: {} as PlanWrapper, plan: {} as PlanWrapper,
activeTab: "time", activeTab: "time",
debounceTimer: null as Timeout, debounceTimer: null as Timeout,
selectedPreviewId: 1,
nextPlanId: 0, nextPlanId: 0,
strategyOpen: false,
}; };
}, },
computed: { computed: {
@ -138,9 +147,6 @@ export default defineComponent({
multiplePlans(): boolean { multiplePlans(): boolean {
return this.repeatingPlans.length !== 0; return this.repeatingPlans.length !== 0;
}, },
selectedPreviewPlanTitle(): string {
return this.previewPlanOptions[this.selectedPreviewId - 1]?.name || "";
},
chargingPlanWarningsProps(): any { chargingPlanWarningsProps(): any {
return this.collectProps(Warnings); return this.collectProps(Warnings);
}, },
@ -154,23 +160,12 @@ export default defineComponent({
? { duration, plan, power, rates, targetTime, currency, smartCostType } ? { duration, plan, power, rates, targetTime, currency, smartCostType }
: null; : null;
}, },
previewPlanOptions(): SelectOption<number>[] { chargingPlanStrategyProps(): any {
const name = (n: number) => `${this.$t("main.targetCharge.preview")} #${n}`; return {
id: this.id,
// static plan precondition: this.effectivePlanPrecondition,
const options = [{ value: 1, name: name(1) }] as SelectOption<number>[]; continuous: this.effectivePlanContinuous,
};
// repeating plans
this.repeatingPlans.forEach((plan, index) => {
const number = index + 2;
options.push({
value: number,
name: name(number),
disabled: !plan.weekdays.length,
});
});
return options;
}, },
alreadyReached(): boolean { alreadyReached(): boolean {
return this.plan.duration === 0; return this.plan.duration === 0;
@ -178,7 +173,7 @@ export default defineComponent({
nextPlanTitle(): string { nextPlanTitle(): string {
return `${this.$t("main.targetCharge.nextPlan")} #${this.nextPlanId}`; return `${this.$t("main.targetCharge.nextPlan")} #${this.nextPlanId}`;
}, },
showPrecondition(): boolean { showStrategy(): boolean {
// only show option if planner forecast has different values // only show option if planner forecast has different values
const slots = this.forecast?.planner || []; const slots = this.forecast?.planner || [];
const values = new Set(slots.map(({ value }) => value)); const values = new Set(slots.map(({ value }) => value));
@ -191,6 +186,12 @@ export default defineComponent({
this.updatePlanDebounced(); this.updatePlanDebounced();
} }
}, },
effectivePlanPrecondition() {
this.updatePlanDebounced();
},
effectivePlanContinuous() {
this.updatePlanDebounced();
},
staticPlan: { staticPlan: {
deep: true, deep: true,
handler(vNew: StaticPlan, vOld: StaticPlan) { handler(vNew: StaticPlan, vOld: StaticPlan) {
@ -203,7 +204,6 @@ export default defineComponent({
deep: true, deep: true,
handler(vNew: RepeatingPlan[], vOld: RepeatingPlan[]) { handler(vNew: RepeatingPlan[], vOld: RepeatingPlan[]) {
if (!deepEqual(vNew, vOld)) { if (!deepEqual(vNew, vOld)) {
this.adjustPreviewId();
this.updatePlanDebounced(); this.updatePlanDebounced();
} }
}, },
@ -213,10 +213,6 @@ export default defineComponent({
this.updatePlanDebounced(); this.updatePlanDebounced();
}, },
methods: { methods: {
selectPreviewPlan(id: number): void {
this.selectedPreviewId = id;
this.updatePlanDebounced();
},
async updatePlanDebounced() { async updatePlanDebounced() {
if (this.noActivePlan) { if (this.noActivePlan) {
await this.updatePlanPreviewDebounced(); await this.updatePlanPreviewDebounced();
@ -224,11 +220,6 @@ export default defineComponent({
await this.updateActivePlanDebounced(); await this.updateActivePlanDebounced();
} }
}, },
adjustPreviewId(): void {
if (this.selectedPreviewId > this.previewPlanOptions.length) {
this.selectedPreviewId = this.previewPlanOptions.length;
}
},
async updateActivePlan(): Promise<void> { async updateActivePlan(): Promise<void> {
try { try {
const res = await this.apiFetchPlan(`loadpoints/${this.id}/plan`); const res = await this.apiFetchPlan(`loadpoints/${this.id}/plan`);
@ -240,22 +231,15 @@ export default defineComponent({
}, },
async fetchStaticPreviewSoc(plan: StaticSocPlan): Promise<PlanResponse | undefined> { async fetchStaticPreviewSoc(plan: StaticSocPlan): Promise<PlanResponse | undefined> {
const timeISO = plan.time.toISOString(); const timeISO = plan.time.toISOString();
const params = plan.precondition ? { precondition: plan.precondition } : undefined; const params: Record<string, unknown> = {};
return await this.apiFetchPlan( 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 params
); );
}, },
async fetchRepeatingPreview(
plan: PartialBy<RepeatingPlan, "active">
): Promise<PlanResponse | undefined> {
return await this.apiFetchPlan(
`loadpoints/${this.id}/plan/repeating/preview/${plan.soc}/${plan.weekdays}/${plan.time}/${encodeURIComponent(plan.tz)}`
);
},
async fetchStaticPreviewEnergy(plan: StaticEnergyPlan): Promise<PlanResponse | undefined> { async fetchStaticPreviewEnergy(plan: StaticEnergyPlan): Promise<PlanResponse | undefined> {
const timeISO = plan.time.toISOString(); const timeISO = plan.time.toISOString();
const params = plan.precondition ? { precondition: plan.precondition } : undefined; const params: Record<string, unknown> = {};
return await this.apiFetchPlan( return await this.apiFetchPlan(
`loadpoints/${this.id}/plan/static/preview/energy/${plan.energy}/${timeISO}`, `loadpoints/${this.id}/plan/static/preview/energy/${plan.energy}/${timeISO}`,
params params
@ -285,8 +269,7 @@ export default defineComponent({
try { try {
let planRes: PlanResponse | undefined = undefined; let planRes: PlanResponse | undefined = undefined;
if (this.staticPlanPreview) {
if (this.selectedPreviewId < 2 && this.staticPlanPreview) {
// static plan // static plan
let plan = this.staticPlanPreview; let plan = this.staticPlanPreview;
if (this.socBasedPlanning) { if (this.socBasedPlanning) {
@ -294,33 +277,14 @@ export default defineComponent({
planRes = await this.fetchStaticPreviewSoc({ planRes = await this.fetchStaticPreviewSoc({
soc: plan.soc, soc: plan.soc,
time: plan.time, time: plan.time,
precondition: plan.precondition,
}); });
} else { } else {
plan = plan as StaticEnergyPlan; plan = plan as StaticEnergyPlan;
planRes = await this.fetchStaticPreviewEnergy({ planRes = await this.fetchStaticPreviewEnergy({
energy: plan.energy, energy: plan.energy,
time: plan.time, time: plan.time,
precondition: plan.precondition,
}); });
} }
} else {
// repeating plan
const plan = this.repeatingPlans[this.selectedPreviewId - 2];
if (!plan) {
return;
}
const { weekdays, soc, time, tz, precondition } = plan;
if (weekdays.length === 0) {
return;
}
planRes = await this.fetchRepeatingPreview({
weekdays,
soc,
time,
tz,
precondition,
});
} }
this.plan = planRes?.data ?? ({} as PlanWrapper); this.plan = planRes?.data ?? ({} as PlanWrapper);
} catch (e) { } catch (e) {
@ -343,8 +307,8 @@ export default defineComponent({
clearTimeout(this.debounceTimer); clearTimeout(this.debounceTimer);
this.debounceTimer = setTimeout(async () => await this.updateActivePlan(), 1000); this.debounceTimer = setTimeout(async () => await this.updateActivePlan(), 1000);
}, },
removeStaticPlan(index: number): void { removeStaticPlan(): void {
this.$emit("static-plan-removed", index); this.$emit("static-plan-removed");
}, },
updateStaticPlan(plan: StaticPlan): void { updateStaticPlan(plan: StaticPlan): void {
this.$emit("static-plan-updated", plan); this.$emit("static-plan-updated", plan);
@ -356,6 +320,9 @@ export default defineComponent({
this.staticPlanPreview = plan; this.staticPlanPreview = plan;
this.updatePlanPreviewDebounced(); this.updatePlanPreviewDebounced();
}, },
updatePlanStrategy(strategy: PlanStrategy): void {
this.$emit("plan-strategy-updated", strategy);
},
}, },
}); });
</script> </script>
@ -364,13 +331,13 @@ export default defineComponent({
h5 { h5 {
position: relative; position: relative;
display: flex; display: flex;
top: -25px; top: -33px;
margin-bottom: -0.5rem; margin-bottom: -0.5rem;
padding: 0 0.5rem; padding: 0 0.5rem;
justify-content: center; justify-content: center;
} }
h5 .inner { h5 .inner {
padding: 0 0.5rem; padding: 0 1rem;
background-color: var(--evcc-box); background-color: var(--evcc-box);
font-weight: normal; font-weight: normal;
color: var(--evcc-gray); color: var(--evcc-gray);

View file

@ -1,134 +0,0 @@
<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";
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,7 +6,6 @@ export interface RepeatingPlan {
tz: string; // timezone like "Europe/Berlin" tz: string; // timezone like "Europe/Berlin"
soc: number; soc: number;
active: boolean; active: boolean;
precondition: number;
} }
export interface PlanWrapper { export interface PlanWrapper {
@ -27,11 +26,14 @@ export type StaticPlan = StaticSocPlan | StaticEnergyPlan;
export interface StaticSocPlan { export interface StaticSocPlan {
soc: number; soc: number;
time: Date; time: Date;
precondition: number;
} }
export interface StaticEnergyPlan { export interface StaticEnergyPlan {
energy: number; energy: number;
time: Date; time: Date;
}
export interface PlanStrategy {
continuous: boolean;
precondition: number; precondition: number;
} }

View file

@ -52,6 +52,7 @@ const baseState = {
planEnergy: 0, planEnergy: 0,
planOverrun: 0, planOverrun: 0,
planPrecondition: 0, planPrecondition: 0,
planContinuous: false,
planProjectedEnd: undefined, planProjectedEnd: undefined,
planProjectedStart: undefined, planProjectedStart: undefined,
planTime: undefined, planTime: undefined,
@ -82,6 +83,8 @@ const baseState = {
capacity: 8, capacity: 8,
features: ["Offline"], features: ["Offline"],
repeatingPlans: [], repeatingPlans: [],
planPrecondition: 0,
planContinuous: false,
}, },
{ {
name: "vehicle_4", name: "vehicle_4",
@ -90,6 +93,8 @@ const baseState = {
capacity: 80, capacity: 80,
features: ["Offline"], features: ["Offline"],
repeatingPlans: [], repeatingPlans: [],
planPrecondition: 0,
planContinuous: false,
}, },
], ],
smartCostType: SMART_COST_TYPE.PRICE_FORECAST, smartCostType: SMART_COST_TYPE.PRICE_FORECAST,

View file

@ -178,10 +178,11 @@ export default defineComponent({
planProjectedEnd: String as PropType<string | null>, planProjectedEnd: String as PropType<string | null>,
planOverrun: { type: Number, default: 0 }, planOverrun: { type: Number, default: 0 },
planEnergy: Number, planEnergy: Number,
planPrecondition: Number,
planTime: String as PropType<string | null>, planTime: String as PropType<string | null>,
effectivePlanTime: String as PropType<string | null>, effectivePlanTime: String as PropType<string | null>,
effectivePlanSoc: Number, effectivePlanSoc: Number,
effectivePlanPrecondition: Number,
effectivePlanContinuous: Boolean,
vehicleProviderLoggedIn: Boolean, vehicleProviderLoggedIn: Boolean,
vehicleProviderLoginPath: String, vehicleProviderLoginPath: String,
vehicleProviderLogoutPath: String, vehicleProviderLogoutPath: String,

View file

@ -48,6 +48,8 @@ const baseArgs = {
capacity: 8, capacity: 8,
features: ["Offline"], features: ["Offline"],
repeatingPlans: [], repeatingPlans: [],
planPrecondition: 0,
planContinuous: false,
}, },
{ {
name: "vehicle_4", name: "vehicle_4",
@ -56,6 +58,8 @@ const baseArgs = {
capacity: 80, capacity: 80,
features: ["Offline"], features: ["Offline"],
repeatingPlans: [], repeatingPlans: [],
planPrecondition: 0,
planContinuous: false,
}, },
{ {
name: "vehicle_5", name: "vehicle_5",
@ -64,6 +68,8 @@ const baseArgs = {
capacity: 0.46, capacity: 0.46,
features: ["Offline"], features: ["Offline"],
repeatingPlans: [], repeatingPlans: [],
planPrecondition: 0,
planContinuous: false,
}, },
], ],
smartCostType: SMART_COST_TYPE.PRICE_FORECAST, smartCostType: SMART_COST_TYPE.PRICE_FORECAST,

View file

@ -10,6 +10,8 @@ const baseState = {
features: [], features: [],
name: "", name: "",
repeatingPlans: [], repeatingPlans: [],
planPrecondition: 0,
planContinuous: false,
}, },
enabled: false, enabled: false,
connected: true, connected: true,

View file

@ -103,6 +103,8 @@ export default defineComponent({
effectiveLimitSoc: Number, effectiveLimitSoc: Number,
effectivePlanSoc: Number, effectivePlanSoc: Number,
effectivePlanTime: String, effectivePlanTime: String,
effectivePlanPrecondition: Number,
effectivePlanContinuous: Boolean,
batteryBoostActive: Boolean, batteryBoostActive: Boolean,
enabled: Boolean, enabled: Boolean,
heating: Boolean, heating: Boolean,
@ -120,7 +122,6 @@ export default defineComponent({
planProjectedEnd: String, planProjectedEnd: String,
planTime: String, planTime: String,
planTimeUnreachable: Boolean, planTimeUnreachable: Boolean,
planPrecondition: Number,
planOverrun: Number, planOverrun: Number,
pvAction: String, pvAction: String,
pvRemainingInterpolated: Number, pvRemainingInterpolated: Number,

View file

@ -246,6 +246,8 @@ export interface Loadpoint {
effectivePlanId: number; effectivePlanId: number;
effectivePlanSoc: number; effectivePlanSoc: number;
effectivePlanTime: string | null; effectivePlanTime: string | null;
effectivePlanPrecondition: number;
effectivePlanContinuous: boolean;
effectivePriority: number; effectivePriority: number;
enableDelay: number; enableDelay: number;
enableThreshold: number; enableThreshold: number;
@ -264,6 +266,7 @@ export interface Loadpoint {
planEnergy: number; planEnergy: number;
planOverrun: number; planOverrun: number;
planPrecondition: number; planPrecondition: number;
planContinuous: boolean;
planProjectedEnd: string | null; planProjectedEnd: string | null;
planProjectedStart: string | null; planProjectedStart: string | null;
planTime: string | null; planTime: string | null;
@ -473,6 +476,8 @@ export interface Vehicle {
limitSoc?: number; limitSoc?: number;
plan?: StaticPlan; plan?: StaticPlan;
repeatingPlans: RepeatingPlan[] | null; repeatingPlans: RepeatingPlan[] | null;
planPrecondition: number;
planContinuous: boolean;
title: string; title: string;
features?: string[]; features?: string[];
capacity?: number; capacity?: number;

View file

@ -57,7 +57,11 @@ const (
EffectivePlanSoc = "effectivePlanSoc" // effective plan soc EffectivePlanSoc = "effectivePlanSoc" // effective plan soc
EffectiveMinCurrent = "effectiveMinCurrent" // effective min current EffectiveMinCurrent = "effectiveMinCurrent" // effective min current
EffectiveMaxCurrent = "effectiveMaxCurrent" // effective max current EffectiveMaxCurrent = "effectiveMaxCurrent" // effective max current
EffectiveLimitSoc = "effectiveLimitSoc" // effective limit soc
EffectiveLimitSoc = "effectiveLimitSoc" // effective limit soc
EffectivePlanStrategy = "effectivePlanStrategy" // effective plan strategy (deprecated, use individual fields)
EffectivePlanPrecondition = "effectivePlanPrecondition" // effective plan precondition duration
EffectivePlanContinuous = "effectivePlanContinuous" // effective plan continuous planning
// measurements // measurements
ChargePower = "chargePower" // charge power ChargePower = "chargePower" // charge power
@ -73,15 +77,17 @@ const (
ChargeRemainingEnergy = "chargeRemainingEnergy" // charge remaining energy ChargeRemainingEnergy = "chargeRemainingEnergy" // charge remaining energy
// plan // plan
Plan = "plan" // charge plan time slots
PlanTime = "planTime" // charge plan finish time goal PlanTime = "planTime" // charge plan finish time goal
PlanEnergy = "planEnergy" // charge plan energy goal PlanEnergy = "planEnergy" // charge plan energy goal
PlanSoc = "planSoc" // charge plan soc 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 PlanActive = "planActive" // charge plan has determined current slot to be an active slot
PlanProjectedStart = "planProjectedStart" // charge plan start time (earliest slot) PlanProjectedStart = "planProjectedStart" // charge plan start time (earliest slot)
PlanProjectedEnd = "planProjectedEnd" // charge plan ends (end of last slot) PlanProjectedEnd = "planProjectedEnd" // charge plan ends (end of last slot)
PlanOverrun = "planOverrun" // charge plan goal not reachable in time PlanOverrun = "planOverrun" // charge plan goal not reachable in time
Plan = "plan" // charge plan time slots PlanStrategy = "planStrategy" // charge plan strategy (precondition, continuous)
PlanPrecondition = "planPrecondition" // charge plan precondition duration
PlanContinuous = "planContinuous" // charge plan continuous planning
// repeating plans // repeating plans
RepeatingPlans = "repeatingPlans" // key to access all repeating plans in db RepeatingPlans = "repeatingPlans" // key to access all repeating plans in db

View file

@ -145,13 +145,13 @@ type Loadpoint struct {
// charge planning // charge planning
planner *planner.Planner planner *planner.Planner
planTime time.Time // time goal planTime time.Time // time goal
planPrecondition time.Duration // precondition duration planStrategy api.PlanStrategy // plan strategy (precondition, continuous)
planEnergy float64 // Plan charge energy in kWh (dumb vehicles) planEnergy float64 // Plan charge energy in kWh (dumb vehicles)
planEnergyOffset float64 // already charged energy in kWh when plan was set planEnergyOffset float64 // already charged energy in kWh when plan was set
planSlotEnd time.Time // current plan slot end time planSlotEnd time.Time // current plan slot end time
planActive bool // charge plan exists and has a currently active slot planActive bool // charge plan exists and has a currently active slot
planOverrunSent bool // notification has been sent already planOverrunSent bool // notification has been sent already
// cached state // cached state
status api.ChargeStatus // Charger status status api.ChargeStatus // Charger status
@ -370,9 +370,14 @@ func (lp *Loadpoint) restoreSettings() {
t, err1 := lp.settings.Time(keys.PlanTime) t, err1 := lp.settings.Time(keys.PlanTime)
v, err2 := lp.settings.Float(keys.PlanEnergy) v, err2 := lp.settings.Float(keys.PlanEnergy)
d, _ := lp.settings.Int(keys.PlanPrecondition)
if err1 == nil && err2 == nil { if err1 == nil && err2 == nil {
lp.setPlanEnergy(t, time.Duration(d)*time.Second, v) lp.setPlanEnergy(t, v)
}
// load plan strategy (continuous mode and precondition duration)
var planStrategy api.PlanStrategy
if err := lp.settings.Json(keys.PlanStrategy, &planStrategy); err == nil {
lp.setPlanStrategy(planStrategy)
} }
} }
@ -686,7 +691,8 @@ func (lp *Loadpoint) Prepare(site site.API, uiChan chan<- util.Param, pushChan c
// restored settings // restored settings
lp.publish(keys.PlanTime, lp.planTime) lp.publish(keys.PlanTime, lp.planTime)
lp.publish(keys.PlanEnergy, lp.planEnergy) lp.publish(keys.PlanEnergy, lp.planEnergy)
lp.publish(keys.PlanPrecondition, lp.planPrecondition) lp.publish(keys.PlanPrecondition, int64(lp.planStrategy.Precondition.Seconds()))
lp.publish(keys.PlanContinuous, lp.planStrategy.Continuous)
lp.publish(keys.LimitSoc, lp.limitSoc) lp.publish(keys.LimitSoc, lp.limitSoc)
lp.publish(keys.LimitEnergy, lp.limitEnergy) lp.publish(keys.LimitEnergy, lp.limitEnergy)
@ -983,7 +989,7 @@ func (lp *Loadpoint) repeatingPlanning() bool {
if !lp.socBasedPlanning() { if !lp.socBasedPlanning() {
return false return false
} }
_, _, _, id := lp.NextVehiclePlan() _, _, id := lp.NextVehiclePlan()
return id > 1 return id > 1
} }

View file

@ -108,6 +108,8 @@ type API interface {
EffectiveMinPower() float64 EffectiveMinPower() float64
// EffectiveMaxPower returns the max charging power taking active phases into account // EffectiveMaxPower returns the max charging power taking active phases into account
EffectiveMaxPower() float64 EffectiveMaxPower() float64
// EffectivePlanStrategy returns the effective plan strategy
EffectivePlanStrategy() api.PlanStrategy
// PublishEffectiveValues publishes effective values for currently attached vehicle // PublishEffectiveValues publishes effective values for currently attached vehicle
PublishEffectiveValues() PublishEffectiveValues()
@ -116,19 +118,21 @@ type API interface {
// //
// GetPlanEnergy returns the charge plan energy // GetPlanEnergy returns the charge plan energy
GetPlanEnergy() (time.Time, time.Duration, float64) GetPlanEnergy() (time.Time, float64)
// SetPlanEnergy sets the charge plan energy // SetPlanEnergy sets the charge plan energy
SetPlanEnergy(time.Time, time.Duration, float64) error SetPlanEnergy(time.Time, float64) error
// GetPlanGoal returns the plan goal and if the goal is soc based // GetPlanGoal returns the plan goal and if the goal is soc based
GetPlanGoal() (float64, bool) GetPlanGoal() (float64, bool)
// GetPlanRequiredDuration returns required duration of plan to reach the goal from current state // GetPlanRequiredDuration returns required duration of plan to reach the goal from current state
GetPlanRequiredDuration(goal, maxPower float64) time.Duration GetPlanRequiredDuration(goal, maxPower float64) time.Duration
// GetPlanPreCondDuration returns the precondition duration // GetPlanStrategy returns the plan strategy
GetPlanPreCondDuration() time.Duration GetPlanStrategy() api.PlanStrategy
// SetPlanStrategy sets the plan strategy
SetPlanStrategy(api.PlanStrategy) error
// SocBasedPlanning determines if the planner is soc based // SocBasedPlanning determines if the planner is soc based
SocBasedPlanning() bool SocBasedPlanning() bool
// GetPlan creates a charging plan // GetPlan creates a charging plan
GetPlan(targetTime time.Time, requiredDuration, precondition time.Duration) api.Rates GetPlan(targetTime time.Time, requiredDuration, precondition time.Duration, continuous bool) api.Rates
// GetSocConfig returns the soc poll settings // GetSocConfig returns the soc poll settings
GetSocConfig() SocConfig GetSocConfig() SocConfig

View file

@ -27,10 +27,11 @@ type DynamicConfig struct {
SmartFeedInPriorityLimit *float64 `json:"smartFeedInPriorityLimit"` SmartFeedInPriorityLimit *float64 `json:"smartFeedInPriorityLimit"`
PlanEnergy float64 `json:"planEnergy"` PlanEnergy float64 `json:"planEnergy"`
PlanTime time.Time `json:"planTime"` PlanTime time.Time `json:"planTime"`
PlanPrecondition int64 `json:"planPrecondition"`
LimitEnergy float64 `json:"limitEnergy"` LimitEnergy float64 `json:"limitEnergy"`
LimitSoc int `json:"limitSoc"` LimitSoc int `json:"limitSoc"`
PlanStrategy api.PlanStrategy `json:"planStrategy"`
Thresholds ThresholdsConfig `json:"thresholds"` Thresholds ThresholdsConfig `json:"thresholds"`
Soc SocConfig `json:"soc"` Soc SocConfig `json:"soc"`
} }
@ -59,7 +60,8 @@ func (payload DynamicConfig) Apply(lp API) error {
lp.SetSmartCostLimit(payload.SmartCostLimit) lp.SetSmartCostLimit(payload.SmartCostLimit)
lp.SetSmartFeedInPriorityLimit(payload.SmartFeedInPriorityLimit) lp.SetSmartFeedInPriorityLimit(payload.SmartFeedInPriorityLimit)
lp.SetThresholds(payload.Thresholds) lp.SetThresholds(payload.Thresholds)
lp.SetPlanEnergy(payload.PlanTime, time.Duration(payload.PlanPrecondition)*time.Second, payload.PlanEnergy) lp.SetPlanEnergy(payload.PlanTime, payload.PlanEnergy)
lp.SetPlanStrategy(payload.PlanStrategy)
lp.SetLimitEnergy(payload.LimitEnergy) lp.SetLimitEnergy(payload.LimitEnergy)
lp.SetLimitSoc(payload.LimitSoc) lp.SetLimitSoc(payload.LimitSoc)

View file

@ -111,6 +111,20 @@ func (mr *MockAPIMockRecorder) EffectivePlanId() *gomock.Call {
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "EffectivePlanId", reflect.TypeOf((*MockAPI)(nil).EffectivePlanId)) return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "EffectivePlanId", reflect.TypeOf((*MockAPI)(nil).EffectivePlanId))
} }
// EffectivePlanStrategy mocks base method.
func (m *MockAPI) EffectivePlanStrategy() api.PlanStrategy {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "EffectivePlanStrategy")
ret0, _ := ret[0].(api.PlanStrategy)
return ret0
}
// EffectivePlanStrategy indicates an expected call of EffectivePlanStrategy.
func (mr *MockAPIMockRecorder) EffectivePlanStrategy() *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "EffectivePlanStrategy", reflect.TypeOf((*MockAPI)(nil).EffectivePlanStrategy))
}
// EffectivePlanTime mocks base method. // EffectivePlanTime mocks base method.
func (m *MockAPI) EffectivePlanTime() time.Time { func (m *MockAPI) EffectivePlanTime() time.Time {
m.ctrl.T.Helper() m.ctrl.T.Helper()
@ -434,27 +448,26 @@ func (mr *MockAPIMockRecorder) GetPhasesConfigured() *gomock.Call {
} }
// GetPlan mocks base method. // GetPlan mocks base method.
func (m *MockAPI) GetPlan(targetTime time.Time, requiredDuration, precondition time.Duration) api.Rates { func (m *MockAPI) GetPlan(targetTime time.Time, requiredDuration, precondition time.Duration, continuous bool) api.Rates {
m.ctrl.T.Helper() m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "GetPlan", targetTime, requiredDuration, precondition) ret := m.ctrl.Call(m, "GetPlan", targetTime, requiredDuration, precondition, continuous)
ret0, _ := ret[0].(api.Rates) ret0, _ := ret[0].(api.Rates)
return ret0 return ret0
} }
// GetPlan indicates an expected call of GetPlan. // GetPlan indicates an expected call of GetPlan.
func (mr *MockAPIMockRecorder) GetPlan(targetTime, requiredDuration, precondition any) *gomock.Call { func (mr *MockAPIMockRecorder) GetPlan(targetTime, requiredDuration, precondition, continuous any) *gomock.Call {
mr.mock.ctrl.T.Helper() mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetPlan", reflect.TypeOf((*MockAPI)(nil).GetPlan), targetTime, requiredDuration, precondition) return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetPlan", reflect.TypeOf((*MockAPI)(nil).GetPlan), targetTime, requiredDuration, precondition, continuous)
} }
// GetPlanEnergy mocks base method. // GetPlanEnergy mocks base method.
func (m *MockAPI) GetPlanEnergy() (time.Time, time.Duration, float64) { func (m *MockAPI) GetPlanEnergy() (time.Time, float64) {
m.ctrl.T.Helper() m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "GetPlanEnergy") ret := m.ctrl.Call(m, "GetPlanEnergy")
ret0, _ := ret[0].(time.Time) ret0, _ := ret[0].(time.Time)
ret1, _ := ret[1].(time.Duration) ret1, _ := ret[1].(float64)
ret2, _ := ret[2].(float64) return ret0, ret1
return ret0, ret1, ret2
} }
// GetPlanEnergy indicates an expected call of GetPlanEnergy. // GetPlanEnergy indicates an expected call of GetPlanEnergy.
@ -478,20 +491,6 @@ func (mr *MockAPIMockRecorder) GetPlanGoal() *gomock.Call {
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetPlanGoal", reflect.TypeOf((*MockAPI)(nil).GetPlanGoal)) 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. // GetPlanRequiredDuration mocks base method.
func (m *MockAPI) GetPlanRequiredDuration(goal, maxPower float64) time.Duration { func (m *MockAPI) GetPlanRequiredDuration(goal, maxPower float64) time.Duration {
m.ctrl.T.Helper() m.ctrl.T.Helper()
@ -506,6 +505,20 @@ func (mr *MockAPIMockRecorder) GetPlanRequiredDuration(goal, maxPower any) *gomo
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetPlanRequiredDuration", reflect.TypeOf((*MockAPI)(nil).GetPlanRequiredDuration), goal, maxPower) return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetPlanRequiredDuration", reflect.TypeOf((*MockAPI)(nil).GetPlanRequiredDuration), goal, maxPower)
} }
// GetPlanStrategy mocks base method.
func (m *MockAPI) GetPlanStrategy() api.PlanStrategy {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "GetPlanStrategy")
ret0, _ := ret[0].(api.PlanStrategy)
return ret0
}
// GetPlanStrategy indicates an expected call of GetPlanStrategy.
func (mr *MockAPIMockRecorder) GetPlanStrategy() *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetPlanStrategy", reflect.TypeOf((*MockAPI)(nil).GetPlanStrategy))
}
// GetPriority mocks base method. // GetPriority mocks base method.
func (m *MockAPI) GetPriority() int { func (m *MockAPI) GetPriority() int {
m.ctrl.T.Helper() m.ctrl.T.Helper()
@ -901,17 +914,31 @@ func (mr *MockAPIMockRecorder) SetPhasesConfigured(arg0 any) *gomock.Call {
} }
// SetPlanEnergy mocks base method. // SetPlanEnergy mocks base method.
func (m *MockAPI) SetPlanEnergy(arg0 time.Time, arg1 time.Duration, arg2 float64) error { func (m *MockAPI) SetPlanEnergy(arg0 time.Time, arg1 float64) error {
m.ctrl.T.Helper() m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "SetPlanEnergy", arg0, arg1, arg2) ret := m.ctrl.Call(m, "SetPlanEnergy", arg0, arg1)
ret0, _ := ret[0].(error) ret0, _ := ret[0].(error)
return ret0 return ret0
} }
// SetPlanEnergy indicates an expected call of SetPlanEnergy. // SetPlanEnergy indicates an expected call of SetPlanEnergy.
func (mr *MockAPIMockRecorder) SetPlanEnergy(arg0, arg1, arg2 any) *gomock.Call { func (mr *MockAPIMockRecorder) SetPlanEnergy(arg0, arg1 any) *gomock.Call {
mr.mock.ctrl.T.Helper() mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SetPlanEnergy", reflect.TypeOf((*MockAPI)(nil).SetPlanEnergy), arg0, arg1, arg2) return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SetPlanEnergy", reflect.TypeOf((*MockAPI)(nil).SetPlanEnergy), arg0, arg1)
}
// SetPlanStrategy mocks base method.
func (m *MockAPI) SetPlanStrategy(arg0 api.PlanStrategy) error {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "SetPlanStrategy", arg0)
ret0, _ := ret[0].(error)
return ret0
}
// SetPlanStrategy indicates an expected call of SetPlanStrategy.
func (mr *MockAPIMockRecorder) SetPlanStrategy(arg0 any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SetPlanStrategy", reflect.TypeOf((*MockAPI)(nil).SetPlanStrategy), arg0)
} }
// SetPriority mocks base method. // SetPriority mocks base method.

View file

@ -343,19 +343,19 @@ func (lp *Loadpoint) SetLimitEnergy(energy float64) {
} }
// GetPlanEnergy returns plan target energy // GetPlanEnergy returns plan target energy
func (lp *Loadpoint) GetPlanEnergy() (time.Time, time.Duration, float64) { func (lp *Loadpoint) GetPlanEnergy() (time.Time, float64) {
lp.RLock() lp.RLock()
defer lp.RUnlock() defer lp.RUnlock()
return lp.getPlanEnergy() return lp.getPlanEnergy()
} }
// getPlanEnergy returns plan target energy // getPlanEnergy returns plan target energy
func (lp *Loadpoint) getPlanEnergy() (time.Time, time.Duration, float64) { func (lp *Loadpoint) getPlanEnergy() (time.Time, float64) {
return lp.planTime, lp.planPrecondition, lp.planEnergy return lp.planTime, lp.planEnergy
} }
// setPlanEnergy sets plan target energy (no mutex) // setPlanEnergy sets plan target energy (no mutex)
func (lp *Loadpoint) setPlanEnergy(finishAt time.Time, precondition time.Duration, energy float64) { func (lp *Loadpoint) setPlanEnergy(finishAt time.Time, energy float64) {
lp.planEnergy = energy lp.planEnergy = energy
lp.publish(keys.PlanEnergy, energy) lp.publish(keys.PlanEnergy, energy)
lp.settings.SetFloat(keys.PlanEnergy, energy) lp.settings.SetFloat(keys.PlanEnergy, energy)
@ -363,16 +363,12 @@ func (lp *Loadpoint) setPlanEnergy(finishAt time.Time, precondition time.Duratio
// remove plan // remove plan
if energy == 0 { if energy == 0 {
finishAt = time.Time{} finishAt = time.Time{}
precondition = 0
} }
lp.planTime = finishAt lp.planTime = finishAt
lp.planPrecondition = precondition
lp.planEnergyOffset = lp.getChargedEnergy() / 1e3 lp.planEnergyOffset = lp.getChargedEnergy() / 1e3
lp.publish(keys.PlanTime, finishAt) lp.publish(keys.PlanTime, finishAt)
lp.publish(keys.PlanPrecondition, precondition)
lp.settings.SetTime(keys.PlanTime, finishAt) lp.settings.SetTime(keys.PlanTime, finishAt)
lp.settings.SetInt(keys.PlanPrecondition, int64(precondition.Seconds()))
if finishAt.IsZero() { if finishAt.IsZero() {
lp.setPlanActive(false) lp.setPlanActive(false)
@ -380,7 +376,7 @@ func (lp *Loadpoint) setPlanEnergy(finishAt time.Time, precondition time.Duratio
} }
// SetPlanEnergy sets plan target energy // SetPlanEnergy sets plan target energy
func (lp *Loadpoint) SetPlanEnergy(finishAt time.Time, precondition time.Duration, energy float64) error { func (lp *Loadpoint) SetPlanEnergy(finishAt time.Time, energy float64) error {
lp.Lock() lp.Lock()
defer lp.Unlock() defer lp.Unlock()
@ -391,14 +387,51 @@ func (lp *Loadpoint) SetPlanEnergy(finishAt time.Time, precondition time.Duratio
lp.log.DEBUG.Printf("set plan energy: %.3gkWh @ %v", energy, finishAt.Round(time.Second).Local()) lp.log.DEBUG.Printf("set plan energy: %.3gkWh @ %v", energy, finishAt.Round(time.Second).Local())
// apply immediately // apply immediately
if lp.planEnergy != energy || lp.planPrecondition != precondition || !lp.planTime.Equal(finishAt) { if lp.planEnergy != energy || !lp.planTime.Equal(finishAt) {
lp.setPlanEnergy(finishAt, precondition, energy) lp.setPlanEnergy(finishAt, energy)
lp.requestUpdate() lp.requestUpdate()
} }
return nil return nil
} }
// setPlanStrategy sets the plan strategy (no mutex)
func (lp *Loadpoint) setPlanStrategy(strategy api.PlanStrategy) error {
if err := lp.settings.SetJson(keys.PlanStrategy, strategy); err != nil {
return err
}
lp.planStrategy = strategy
lp.publish(keys.PlanPrecondition, int64(strategy.Precondition.Seconds()))
lp.publish(keys.PlanContinuous, strategy.Continuous)
lp.requestUpdate()
return nil
}
// SetPlanStrategy sets the plan strategy
func (lp *Loadpoint) SetPlanStrategy(strategy api.PlanStrategy) error {
lp.Lock()
defer lp.Unlock()
lp.log.DEBUG.Printf("set plan strategy: continuous=%v, precondition=%v", strategy.Continuous, strategy.Precondition)
return lp.setPlanStrategy(strategy)
}
// getPlanStrategy returns the plan strategy (no mutex)
func (lp *Loadpoint) getPlanStrategy() api.PlanStrategy {
return lp.planStrategy
}
// GetPlanStrategy returns the plan strategy
func (lp *Loadpoint) GetPlanStrategy() api.PlanStrategy {
lp.RLock()
defer lp.RUnlock()
return lp.getPlanStrategy()
}
// GetSoc returns the PV mode threshold settings // GetSoc returns the PV mode threshold settings
func (lp *Loadpoint) GetSocConfig() loadpoint.SocConfig { func (lp *Loadpoint) GetSocConfig() loadpoint.SocConfig {
lp.RLock() lp.RLock()

View file

@ -12,6 +12,7 @@ import (
// PublishEffectiveValues publishes all effective values // PublishEffectiveValues publishes all effective values
func (lp *Loadpoint) PublishEffectiveValues() { func (lp *Loadpoint) PublishEffectiveValues() {
strategy := lp.EffectivePlanStrategy()
lp.publish(keys.EffectivePriority, lp.EffectivePriority()) lp.publish(keys.EffectivePriority, lp.EffectivePriority())
lp.publish(keys.EffectivePlanId, lp.EffectivePlanId()) lp.publish(keys.EffectivePlanId, lp.EffectivePlanId())
lp.publish(keys.EffectivePlanTime, lp.EffectivePlanTime()) lp.publish(keys.EffectivePlanTime, lp.EffectivePlanTime())
@ -19,6 +20,8 @@ func (lp *Loadpoint) PublishEffectiveValues() {
lp.publish(keys.EffectiveMinCurrent, lp.effectiveMinCurrent()) lp.publish(keys.EffectiveMinCurrent, lp.effectiveMinCurrent())
lp.publish(keys.EffectiveMaxCurrent, lp.effectiveMaxCurrent()) lp.publish(keys.EffectiveMaxCurrent, lp.effectiveMaxCurrent())
lp.publish(keys.EffectiveLimitSoc, lp.EffectiveLimitSoc()) lp.publish(keys.EffectiveLimitSoc, lp.EffectiveLimitSoc())
lp.publish(keys.EffectivePlanPrecondition, int64(strategy.Precondition.Seconds()))
lp.publish(keys.EffectivePlanContinuous, strategy.Continuous)
} }
// EffectivePriority returns the effective priority // EffectivePriority returns the effective priority
@ -32,11 +35,10 @@ func (lp *Loadpoint) EffectivePriority() int {
} }
type plan struct { type plan struct {
Id int Id int
Start time.Time // last possible start time Start time.Time // last possible start time
End time.Time // user-selected finish time End time.Time // user-selected finish time
Precondition time.Duration Soc int
Soc int
} }
func (lp *Loadpoint) nextActivePlan(maxPower float64, plans []plan) *plan { func (lp *Loadpoint) nextActivePlan(maxPower float64, plans []plan) *plan {
@ -60,20 +62,20 @@ func (lp *Loadpoint) nextActivePlan(maxPower float64, plans []plan) *plan {
} }
// NextVehiclePlan returns the next vehicle plan time, soc and id // NextVehiclePlan returns the next vehicle plan time, soc and id
func (lp *Loadpoint) NextVehiclePlan() (time.Time, time.Duration, int, int) { func (lp *Loadpoint) NextVehiclePlan() (time.Time, int, int) {
lp.RLock() lp.RLock()
defer lp.RUnlock() defer lp.RUnlock()
return lp.nextVehiclePlan() return lp.nextVehiclePlan()
} }
// nextVehiclePlan returns the next vehicle plan time, precondition duration, soc and id // nextVehiclePlan returns the next vehicle plan time, soc, id
func (lp *Loadpoint) nextVehiclePlan() (time.Time, time.Duration, int, int) { func (lp *Loadpoint) nextVehiclePlan() (time.Time, int, int) {
if v := lp.GetVehicle(); v != nil { if v := lp.GetVehicle(); v != nil {
var plans []plan var plans []plan
// static plan // static plan
if planTime, precondition, soc := vehicle.Settings(lp.log, v).GetPlanSoc(); soc != 0 { if planTime, soc := vehicle.Settings(lp.log, v).GetPlanSoc(); soc != 0 {
plans = append(plans, plan{Id: 1, Precondition: precondition, Soc: soc, End: planTime}) plans = append(plans, plan{Id: 1, Soc: soc, End: planTime})
} }
// repeating plans // repeating plans
@ -88,28 +90,27 @@ func (lp *Loadpoint) nextVehiclePlan() (time.Time, time.Duration, int, int) {
continue continue
} }
precondition := time.Duration(rp.Precondition) * time.Second plans = append(plans, plan{Id: index + 2, Soc: rp.Soc, End: planTime})
plans = append(plans, plan{Id: index + 2, Precondition: precondition, Soc: rp.Soc, End: planTime})
} }
// calculate earliest required plan start // calculate earliest required plan start
if plan := lp.nextActivePlan(lp.effectiveMaxPower(), plans); plan != nil { if plan := lp.nextActivePlan(lp.effectiveMaxPower(), plans); plan != nil {
return plan.End, plan.Precondition, plan.Soc, plan.Id return plan.End, plan.Soc, plan.Id
} }
} }
return time.Time{}, 0, 0, 0 return time.Time{}, 0, 0
} }
// EffectivePlanSoc returns the soc target for the current plan // EffectivePlanSoc returns the soc target for the current plan
func (lp *Loadpoint) EffectivePlanSoc() int { func (lp *Loadpoint) EffectivePlanSoc() int {
_, _, soc, _ := lp.NextVehiclePlan() _, soc, _ := lp.NextVehiclePlan()
return soc return soc
} }
// EffectivePlanId returns the id for the current plan // EffectivePlanId returns the id for the current plan
func (lp *Loadpoint) EffectivePlanId() int { func (lp *Loadpoint) EffectivePlanId() int {
if lp.socBasedPlanning() { if lp.socBasedPlanning() {
_, _, _, id := lp.NextVehiclePlan() _, _, id := lp.NextVehiclePlan()
return id return id
} }
if lp.planEnergy > 0 { if lp.planEnergy > 0 {
@ -122,11 +123,11 @@ func (lp *Loadpoint) EffectivePlanId() int {
// EffectivePlanTime returns the effective plan time // EffectivePlanTime returns the effective plan time
func (lp *Loadpoint) EffectivePlanTime() time.Time { func (lp *Loadpoint) EffectivePlanTime() time.Time {
if lp.socBasedPlanning() { if lp.socBasedPlanning() {
ts, _, _, _ := lp.NextVehiclePlan() ts, _, _ := lp.NextVehiclePlan()
return ts return ts
} }
ts, _, _ := lp.GetPlanEnergy() ts, _ := lp.GetPlanEnergy()
return ts return ts
} }
@ -239,3 +240,20 @@ func (lp *Loadpoint) effectiveMaxPower() float64 {
} }
return Voltage * lp.effectiveMaxCurrent() * float64(lp.maxActivePhases()) return Voltage * lp.effectiveMaxCurrent() * float64(lp.maxActivePhases())
} }
// EffectivePlanStrategy returns the effective plan strategy
func (lp *Loadpoint) EffectivePlanStrategy() api.PlanStrategy {
lp.RLock()
defer lp.RUnlock()
return lp.getEffectivePlanStrategy()
}
func (lp *Loadpoint) getEffectivePlanStrategy() api.PlanStrategy {
if v := lp.GetVehicle(); v != nil {
if lp.socBasedPlanning() {
return vehicle.Settings(lp.log, v).GetPlanStrategy()
}
}
return lp.getPlanStrategy()
}

View file

@ -30,9 +30,9 @@ func (lp *Loadpoint) finishPlan() {
if lp.repeatingPlanning() { if lp.repeatingPlanning() {
return // noting to do return // noting to do
} else if !lp.socBasedPlanning() { } else if !lp.socBasedPlanning() {
lp.setPlanEnergy(time.Time{}, 0, 0) lp.setPlanEnergy(time.Time{}, 0)
} else if v := lp.GetVehicle(); v != nil { } else if v := lp.GetVehicle(); v != nil {
vehicle.Settings(lp.log, v).SetPlanSoc(time.Time{}, 0, 0) vehicle.Settings(lp.log, v).SetPlanSoc(time.Time{}, 0)
} }
} }
@ -67,36 +67,25 @@ func (lp *Loadpoint) GetPlanGoal() (float64, bool) {
defer lp.RUnlock() defer lp.RUnlock()
if lp.socBasedPlanning() { if lp.socBasedPlanning() {
_, _, soc, _ := lp.nextVehiclePlan() _, soc, _ := lp.nextVehiclePlan()
return float64(soc), true return float64(soc), true
} }
_, _, limit := lp.getPlanEnergy() _, limit := lp.getPlanEnergy()
return limit, false 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 // GetPlan creates a charging plan for given time and duration
// The plan is sorted by time // The plan is sorted by time
func (lp *Loadpoint) GetPlan(targetTime time.Time, requiredDuration, precondition time.Duration) api.Rates { func (lp *Loadpoint) GetPlan(targetTime time.Time, requiredDuration, precondition time.Duration, continuous bool) api.Rates {
if lp.planner == nil || targetTime.IsZero() { if lp.planner == nil || targetTime.IsZero() {
return nil return nil
} }
return lp.planner.Plan(requiredDuration, precondition, targetTime) lp.log.TRACE.Printf("plan: creating plan with continuous=%v, precondition=%v, duration=%v, target=%v",
continuous, precondition, requiredDuration.Round(time.Second), targetTime.Round(time.Second).Local())
return lp.planner.Plan(requiredDuration, precondition, targetTime, continuous)
} }
// plannerActive checks if the charging plan has a currently active slot // plannerActive checks if the charging plan has a currently active slot
@ -146,7 +135,9 @@ func (lp *Loadpoint) plannerActive() (active bool) {
return false return false
} }
plan = lp.GetPlan(planTime, requiredDuration, lp.GetPlanPreCondDuration()) strategy := lp.getEffectivePlanStrategy()
plan = lp.GetPlan(planTime, requiredDuration, strategy.Precondition, strategy.Continuous)
if plan == nil { if plan == nil {
return false return false
} }

View file

@ -4,6 +4,7 @@ import (
"time" "time"
"github.com/evcc-io/evcc/api" "github.com/evcc-io/evcc/api"
"github.com/samber/lo"
) )
// Start returns the earliest slot's start time // Start returns the earliest slot's start time
@ -46,6 +47,9 @@ func AverageCost(plan api.Rates) float64 {
duration += slotDuration duration += slotDuration
cost += float64(slotDuration) * slot.Value cost += float64(slotDuration) * slot.Value
} }
if duration == 0 {
return 0
}
return cost / float64(duration) return cost / float64(duration)
} }
@ -80,3 +84,78 @@ func IsFirst(r api.Rate, plan api.Rates) bool {
} }
return true return true
} }
// clampRates filters rates to the given time window and adjusts boundary slots
func clampRates(rates api.Rates, start, end time.Time) api.Rates {
res := make(api.Rates, 0, len(rates)+2)
for _, r := range rates {
// slot before continuous plan
if !r.End.After(start) {
continue
}
// slot after continuous plan
if !r.Start.Before(end) {
continue
}
// calculate adjusted bounds
adjustedStart := r.Start
if adjustedStart.Before(start) {
adjustedStart = start
}
adjustedEnd := r.End
if adjustedEnd.After(end) {
adjustedEnd = end
}
// skip if adjustment would create invalid slot
if !adjustedEnd.After(adjustedStart) {
continue
}
slot := r
slot.Start = adjustedStart
slot.End = adjustedEnd
res = append(res, slot)
}
return res
}
// findContinuousWindow finds the cheapest continuous window of slots for the given duration.
// - rates are filtered to [now, targetTime] window by caller
// Returns the selected rates.
func findContinuousWindow(rates api.Rates, effectiveDuration time.Duration, targetTime time.Time) api.Rates {
var bestCost *float64
var bestIndex *int
for i := range rates {
windowEnd := rates[i].Start.Add(effectiveDuration)
if windowEnd.After(targetTime) {
break
}
cost := lo.SumBy(clampRates(rates[i:], rates[i].Start, windowEnd), func(r api.Rate) float64 {
return float64(r.End.Sub(r.Start)) * r.Value
})
// Prefer later start if equal cost
if bestCost == nil || cost <= *bestCost {
bestCost = &cost
bestIndex = &i
}
}
// No valid window found
if bestIndex == nil {
return nil
}
// Build the best window only once
windowEnd := rates[*bestIndex].Start.Add(effectiveDuration)
return clampRates(rates[*bestIndex:], rates[*bestIndex].Start, windowEnd)
}

View file

@ -47,3 +47,49 @@ func TestIsFirst(t *testing.T) {
// ensure single slot is always first // ensure single slot is always first
require.True(t, IsFirst(first, []api.Rate{first})) require.True(t, IsFirst(first, []api.Rate{first}))
} }
func TestDuration(t *testing.T) {
now := time.Now()
plan := api.Rates{
{Start: now, End: now.Add(time.Hour)},
{Start: now.Add(time.Hour), End: now.Add(time.Hour)}, // zero - without impact
{Start: now.Add(2 * time.Hour), End: now.Add(3 * time.Hour)},
}
require.Equal(t, 2*time.Hour, Duration(plan))
require.Equal(t, time.Duration(0), Duration(api.Rates{}))
}
func TestAverageCost(t *testing.T) {
now := time.Now()
plan := api.Rates{
{Start: now, End: now.Add(30 * time.Minute), Value: 10.0}, // 0.5h * 10 = 5
{Start: now, End: now, Value: 999.0}, // zero - ignored
{Start: now.Add(30 * time.Minute), End: now.Add(2 * time.Hour), Value: 20.0}, // 1.5h * 20 = 30
}
require.Equal(t, 17.5, AverageCost(plan)) // (5 + 30) / 2h = 17.5
require.Equal(t, 0.0, AverageCost(api.Rates{}))
require.Equal(t, 0.0, AverageCost(api.Rates{{Start: now, End: now, Value: 10}}))
}
func TestStartEnd(t *testing.T) {
now := time.Now()
plan := api.Rates{
{Start: now.Add(2 * time.Hour), End: now.Add(3 * time.Hour)},
{Start: now, End: now.Add(time.Hour)},
}
require.Equal(t, now, Start(plan))
require.Equal(t, now.Add(3*time.Hour), End(plan))
require.True(t, Start(api.Rates{}).IsZero())
require.True(t, End(api.Rates{}).IsZero())
}
func TestSlotAt(t *testing.T) {
now := time.Now()
plan := api.Rates{
{Start: now, End: now.Add(time.Hour), Value: 1},
{Start: now.Add(time.Hour), End: now.Add(2 * time.Hour), Value: 2},
}
require.Equal(t, 1.0, SlotAt(now.Add(30*time.Minute), plan).Value)
require.Equal(t, 2.0, SlotAt(now.Add(90*time.Minute), plan).Value)
require.True(t, SlotAt(now.Add(3*time.Hour), plan).IsZero())
}

View file

@ -32,27 +32,13 @@ func New(log *util.Logger, tariff api.Tariff, opt ...func(t *Planner)) *Planner
} }
// plan creates a lowest-cost plan or required duration. // plan creates a lowest-cost plan or required duration.
// It MUST already established that // It MUST already be established that:
// - rates are sorted in ascending order by cost and descending order by start time (prefer late slots) // - rates are sorted in ascending order by cost and descending order by start time (prefer late slots)
// - target time and required duration are before end of rates // - rates are filtered to [now, targetTime] window by caller
func (t *Planner) plan(rates api.Rates, requiredDuration time.Duration, targetTime time.Time) api.Rates { func (t *Planner) plan(rates api.Rates, requiredDuration time.Duration, targetTime time.Time) api.Rates {
var plan api.Rates var plan api.Rates
for _, source := range rates { for _, slot := range rates {
// slot not relevant
if !(source.End.After(t.clock.Now()) && source.Start.Before(targetTime)) {
continue
}
// adjust slot start and end
slot := source
if slot.Start.Before(t.clock.Now()) {
slot.Start = t.clock.Now()
}
if slot.End.After(targetTime) {
slot.End = targetTime
}
slotDuration := slot.End.Sub(slot.Start) slotDuration := slot.End.Sub(slot.Start)
requiredDuration -= slotDuration requiredDuration -= slotDuration
@ -65,10 +51,6 @@ func (t *Planner) plan(rates api.Rates, requiredDuration time.Duration, targetTi
slot.End = slot.End.Add(requiredDuration) slot.End = slot.End.Add(requiredDuration)
} }
requiredDuration = 0 requiredDuration = 0
if slot.End.Before(slot.Start) {
panic("slot end before start")
}
} }
plan = append(plan, slot) plan = append(plan, slot)
@ -82,34 +64,9 @@ func (t *Planner) plan(rates api.Rates, requiredDuration time.Duration, targetTi
return plan return plan
} }
// Plan creates a continuous emergency charging plan // continuousPlan creates a continuous emergency charging plan
func (t *Planner) continuousPlan(rates api.Rates, start, end time.Time) api.Rates { func continuousPlan(rates api.Rates, start, end time.Time) api.Rates {
rates.Sort() res := clampRates(rates, start, end)
res := make(api.Rates, 0, len(rates)+2)
for _, r := range rates {
// slot before continuous plan
if !r.End.After(start) {
continue
}
// slot after continuous plan
if !r.Start.Before(end) {
continue
}
// adjust first slot
if r.Start.Before(start) && r.End.After(start) {
r.Start = start
}
// adjust last slot
if r.Start.Before(end) && r.End.After(end) {
r.End = end
}
res = append(res, r)
}
if len(res) == 0 { if len(res) == 0 {
return []api.Rate{{ return []api.Rate{{
@ -136,14 +93,16 @@ func (t *Planner) continuousPlan(rates api.Rates, start, end time.Time) api.Rate
return res return res
} }
func (t *Planner) Plan(requiredDuration, precondition time.Duration, targetTime time.Time) api.Rates { func (t *Planner) Plan(requiredDuration, precondition time.Duration, targetTime time.Time, continuous bool) api.Rates {
if t == nil || requiredDuration <= 0 { if t == nil || requiredDuration <= 0 {
return nil return nil
} }
now := t.clock.Now().Truncate(time.Second)
latestStart := targetTime.Add(-requiredDuration) latestStart := targetTime.Add(-requiredDuration)
if latestStart.Before(t.clock.Now()) { if latestStart.Before(now) {
latestStart = t.clock.Now() latestStart = now
targetTime = latestStart.Add(requiredDuration) targetTime = latestStart.Add(requiredDuration)
} }
@ -169,15 +128,7 @@ func (t *Planner) Plan(requiredDuration, precondition time.Duration, targetTime
// consume remaining time // consume remaining time
if t.clock.Until(targetTime) <= requiredDuration { if t.clock.Until(targetTime) <= requiredDuration {
return t.continuousPlan(rates, latestStart, targetTime) return continuousPlan(rates, latestStart, targetTime)
}
// cut off all rates after target time
for i := 1; i < len(rates); i++ {
if !rates[i].Start.Before(targetTime) {
rates = rates[:i]
break
}
} }
// rates are by default sorted by date, oldest to newest // rates are by default sorted by date, oldest to newest
@ -192,7 +143,7 @@ func (t *Planner) Plan(requiredDuration, precondition time.Duration, targetTime
} }
// need to use some of the available slots // need to use some of the available slots
t.log.DEBUG.Printf("target time beyond available slots- reducing plan horizon from %v to %v", t.log.DEBUG.Printf("planner: target time beyond available slots- reducing plan horizon from %v to %v",
requiredDuration.Round(time.Second), durationAfterRates.Round(time.Second)) requiredDuration.Round(time.Second), durationAfterRates.Round(time.Second))
targetTime = last targetTime = last
@ -200,22 +151,51 @@ func (t *Planner) Plan(requiredDuration, precondition time.Duration, targetTime
precondition = max(precondition-durationAfterRates, 0) precondition = max(precondition-durationAfterRates, 0)
} }
rates = clampRates(rates, now, targetTime)
// don't precondition longer than charging duration // don't precondition longer than charging duration
precondition = min(precondition, requiredDuration) precondition = min(precondition, requiredDuration)
// reduce target time by precondition duration // reduce target time by precondition duration
targetTime = targetTime.Add(-precondition) targetTime = targetTime.Add(-precondition)
requiredDuration = max(requiredDuration-precondition, 0)
// separate precond rates, to be appended to plan afterwards // separate precond rates, to be appended to plan afterwards
var precond api.Rates var precond api.Rates
if precondition > 0 { if precondition > 0 {
rates, precond = splitPreconditionSlots(rates, targetTime) rates, precond = splitPreconditionSlots(rates, targetTime)
// reduce required duration by precondition, skip planning if required
requiredDuration = max(requiredDuration-precondition, 0)
if requiredDuration == 0 {
return precond
}
} }
// create plan unless only precond slots remaining // create plan unless only precond slots remaining
var plan api.Rates var plan api.Rates
if requiredDuration > 0 { if continuous {
// check if available tariff slots span is sufficient for sliding window algorithm
// verify that actual tariff data covers enough duration (may have gaps or start late)
if len(rates) > 0 {
start := rates[0].Start
if start.Before(now) {
start = now
}
end := rates[len(rates)-1].End
if end.After(targetTime) {
end = targetTime
}
// available window too small for sliding window - charge continuously from now to target
if end.Sub(start) < requiredDuration {
return continuousPlan(append(rates, precond...), now, targetTime.Add(precondition))
}
}
// find cheapest continuous window
plan = findContinuousWindow(rates, requiredDuration, targetTime)
} else {
// sort rates by price and time // sort rates by price and time
slices.SortStableFunc(rates, sortByCost) slices.SortStableFunc(rates, sortByCost)

View file

@ -0,0 +1,717 @@
package planner
import (
"testing"
"time"
"github.com/benbjohnson/clock"
"github.com/evcc-io/evcc/api"
"github.com/evcc-io/evcc/tariff"
"github.com/evcc-io/evcc/util"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go.uber.org/mock/gomock"
)
func TestContinuous_CheapestContiguousSlots(t *testing.T) {
now := time.Date(1970, time.January, 1, 0, 0, 0, 0, time.UTC)
c := clock.NewMock()
c.Set(now)
ctrl := gomock.NewController(t)
rates := api.Rates{
{Start: now, End: now.Add(1 * time.Hour), Value: 0.09},
{Start: now.Add(1 * time.Hour), End: now.Add(2 * time.Hour), Value: 0.20},
{Start: now.Add(2 * time.Hour), End: now.Add(3 * time.Hour), Value: 0.10},
{Start: now.Add(3 * time.Hour), End: now.Add(4 * time.Hour), Value: 0.11},
{Start: now.Add(4 * time.Hour), End: now.Add(5 * time.Hour), Value: 0.11},
{Start: now.Add(5 * time.Hour), End: now.Add(6 * time.Hour), Value: 0.25},
}
trf := api.NewMockTariff(ctrl)
trf.EXPECT().Rates().AnyTimes().Return(rates, nil)
p := &Planner{
log: util.NewLogger("foo"),
clock: c,
tariff: trf,
}
plan := p.Plan(2*time.Hour, 0, now.Add(6*time.Hour), true)
require.Len(t, plan, 2)
assert.Equal(t, rates[2].Start, plan[0].Start)
assert.Equal(t, rates[3].End, plan[len(plan)-1].End)
assert.Equal(t, rates[2].Value, plan[0].Value)
assert.Equal(t, rates[3].Value, plan[1].Value)
}
func TestContinuous_WindowWithPastRates(t *testing.T) {
now := time.Date(1970, time.January, 1, 0, 0, 0, 0, time.UTC)
c := clock.NewMock()
c.Set(now)
ctrl := gomock.NewController(t)
rates := api.Rates{
{Start: now.Add(-3 * time.Hour), End: now.Add(-2 * time.Hour), Value: 0.05},
{Start: now.Add(-2 * time.Hour), End: now.Add(-1 * time.Hour), Value: 0.08},
{Start: now.Add(-1 * time.Hour), End: now, Value: 0.07},
{Start: now, End: now.Add(1 * time.Hour), Value: 0.20},
{Start: now.Add(1 * time.Hour), End: now.Add(2 * time.Hour), Value: 0.09},
{Start: now.Add(2 * time.Hour), End: now.Add(3 * time.Hour), Value: 0.10},
{Start: now.Add(3 * time.Hour), End: now.Add(4 * time.Hour), Value: 0.15},
{Start: now.Add(4 * time.Hour), End: now.Add(5 * time.Hour), Value: 0.11},
{Start: now.Add(5 * time.Hour), End: now.Add(6 * time.Hour), Value: 0.25},
}
trf := api.NewMockTariff(ctrl)
trf.EXPECT().Rates().AnyTimes().Return(rates, nil)
p := &Planner{
log: util.NewLogger("foo"),
clock: c,
tariff: trf,
}
targetTime := now.Add(6 * time.Hour)
requiredDuration := 2 * time.Hour
plan := p.Plan(requiredDuration, 0, targetTime, true)
require.NotEmpty(t, plan)
require.Len(t, plan, 2)
assert.False(t, plan[0].Start.Before(now))
assert.Equal(t, now.Add(1*time.Hour), plan[0].Start)
assert.Equal(t, now.Add(3*time.Hour), plan[len(plan)-1].End)
assert.Equal(t, 0.09, plan[0].Value)
assert.Equal(t, 0.10, plan[1].Value)
}
func TestContinuous_WindowAllRatesInPast(t *testing.T) {
now := time.Date(1970, time.January, 1, 0, 0, 0, 0, time.UTC)
c := clock.NewMock()
c.Set(now)
ctrl := gomock.NewController(t)
rates := api.Rates{
{Start: now.Add(-6 * time.Hour), End: now.Add(-5 * time.Hour), Value: 0.05},
{Start: now.Add(-5 * time.Hour), End: now.Add(-4 * time.Hour), Value: 0.08},
{Start: now.Add(-4 * time.Hour), End: now.Add(-3 * time.Hour), Value: 0.07},
{Start: now.Add(-3 * time.Hour), End: now.Add(-2 * time.Hour), Value: 0.09},
{Start: now.Add(-2 * time.Hour), End: now.Add(-1 * time.Hour), Value: 0.10},
{Start: now.Add(-1 * time.Hour), End: now, Value: 0.11},
}
trf := api.NewMockTariff(ctrl)
trf.EXPECT().Rates().AnyTimes().Return(rates, nil)
p := &Planner{
log: util.NewLogger("foo"),
clock: c,
tariff: trf,
}
targetTime := now.Add(3 * time.Hour)
requiredDuration := 2 * time.Hour
plan := p.Plan(requiredDuration, 0, targetTime, true) // continuous mode
// When all rates are in the past and target is in future, expect nil plan
assert.Empty(t, plan, "plan should be nil when all rates are in the past")
}
// TestContinuous_WindowRatesSpanningPastAndFuture tests continuous mode with rates
// spanning from past to future, where the optimal window would start in the past
func TestContinuous_WindowRatesSpanningPastAndFuture(t *testing.T) {
now := time.Date(1970, time.January, 1, 0, 0, 0, 0, time.UTC)
c := clock.NewMock()
c.Set(now)
ctrl := gomock.NewController(t)
// Rates spanning from 3h before now to 6h after now
// The cheapest window would be -3h to -1h, but that's in the past
rates := api.Rates{
{Start: now.Add(-3 * time.Hour), End: now.Add(-2 * time.Hour), Value: 0.05}, // cheapest, but past
{Start: now.Add(-2 * time.Hour), End: now.Add(-1 * time.Hour), Value: 0.06}, // cheap, but past
{Start: now.Add(-1 * time.Hour), End: now, Value: 0.12}, // partially past
{Start: now, End: now.Add(1 * time.Hour), Value: 0.15},
{Start: now.Add(1 * time.Hour), End: now.Add(2 * time.Hour), Value: 0.08}, // cheapest future
{Start: now.Add(2 * time.Hour), End: now.Add(3 * time.Hour), Value: 0.09}, // second cheapest future
{Start: now.Add(3 * time.Hour), End: now.Add(4 * time.Hour), Value: 0.18},
{Start: now.Add(4 * time.Hour), End: now.Add(5 * time.Hour), Value: 0.14},
{Start: now.Add(5 * time.Hour), End: now.Add(6 * time.Hour), Value: 0.20},
}
trf := api.NewMockTariff(ctrl)
trf.EXPECT().Rates().AnyTimes().Return(rates, nil)
p := &Planner{
log: util.NewLogger("foo"),
clock: c,
tariff: trf,
}
targetTime := now.Add(6 * time.Hour)
requiredDuration := 2 * time.Hour
plan := p.Plan(requiredDuration, 0, targetTime, true) // continuous mode
require.NotEmpty(t, plan)
require.Len(t, plan, 2)
// Critical: plan must start at or after now, even if cheaper rates existed in the past
assert.False(t, plan[0].Start.Before(now), "plan must not start in the past")
// Should find cheapest 2-hour window starting from now or later
// Expected: 1h-3h window (two slots with prices 0.08 and 0.09)
assert.Equal(t, now.Add(1*time.Hour), plan[0].Start, "start should be at cheapest future window")
assert.Equal(t, now.Add(3*time.Hour), plan[len(plan)-1].End, "end should match 2-hour window")
assert.Equal(t, 0.08, plan[0].Value, "first slot should have actual price")
assert.Equal(t, 0.09, plan[1].Value, "second slot should have actual price")
}
// TestContinuous_WindowRatesStartInFuture tests continuous mode when tariff data
// starts in the future, but target time is within the tariff data range
func TestContinuous_WindowRatesStartInFuture(t *testing.T) {
now := time.Date(1970, time.January, 1, 0, 0, 0, 0, time.UTC)
c := clock.NewMock()
c.Set(now)
ctrl := gomock.NewController(t)
rates := api.Rates{
{Start: now.Add(1 * time.Hour), End: now.Add(2 * time.Hour), Value: 0.20},
{Start: now.Add(2 * time.Hour), End: now.Add(3 * time.Hour), Value: 0.08},
{Start: now.Add(3 * time.Hour), End: now.Add(4 * time.Hour), Value: 0.09},
{Start: now.Add(4 * time.Hour), End: now.Add(5 * time.Hour), Value: 0.15},
{Start: now.Add(5 * time.Hour), End: now.Add(6 * time.Hour), Value: 0.18},
}
trf := api.NewMockTariff(ctrl)
trf.EXPECT().Rates().AnyTimes().Return(rates, nil)
p := &Planner{
log: util.NewLogger("foo"),
clock: c,
tariff: trf,
}
targetTime := now.Add(5 * time.Hour)
requiredDuration := 2 * time.Hour
plan := p.Plan(requiredDuration, 0, targetTime, true) // continuous mode
require.NotEmpty(t, plan)
require.Len(t, plan, 2)
// Plan must not start in the past
assert.False(t, plan[0].Start.Before(now), "plan must not start in the past")
// Should find cheapest 2-hour window within available rates
// Expected: 2h-4h window (two slots with prices 0.08 and 0.09)
assert.Equal(t, now.Add(2*time.Hour), plan[0].Start, "start should be at cheapest window in future rates")
assert.Equal(t, now.Add(4*time.Hour), plan[len(plan)-1].End, "end should match 2-hour window")
assert.Equal(t, 0.08, plan[0].Value, "first slot should have actual price")
assert.Equal(t, 0.09, plan[1].Value, "second slot should have actual price")
}
func TestContinuous_WindowLateChargingPreference(t *testing.T) {
now := time.Date(1970, time.January, 1, 0, 0, 0, 0, time.UTC)
c := clock.NewMock()
c.Set(now)
ctrl := gomock.NewController(t)
rates := api.Rates{
{Start: now, End: now.Add(1 * time.Hour), Value: 0.10},
{Start: now.Add(1 * time.Hour), End: now.Add(2 * time.Hour), Value: 0.10},
{Start: now.Add(2 * time.Hour), End: now.Add(3 * time.Hour), Value: 0.10},
{Start: now.Add(3 * time.Hour), End: now.Add(4 * time.Hour), Value: 0.10},
{Start: now.Add(4 * time.Hour), End: now.Add(5 * time.Hour), Value: 0.10},
{Start: now.Add(5 * time.Hour), End: now.Add(6 * time.Hour), Value: 0.15},
}
trf := api.NewMockTariff(ctrl)
trf.EXPECT().Rates().AnyTimes().Return(rates, nil)
p := &Planner{
log: util.NewLogger("foo"),
clock: c,
tariff: trf,
}
targetTime := now.Add(6 * time.Hour)
requiredDuration := 2 * time.Hour
plan := p.Plan(requiredDuration, 0, targetTime, true) // continuous mode
require.NotEmpty(t, plan)
require.Len(t, plan, 2)
// Should select the latest window with equal cost (3h-5h)
// All windows from 0h-2h, 1h-3h, 2h-4h, and 3h-5h have the same total cost
// But we prefer late charging, so 3h-5h should be selected
assert.Equal(t, now.Add(3*time.Hour), plan[0].Start, "should select latest window with equal cost")
assert.Equal(t, now.Add(5*time.Hour), plan[len(plan)-1].End, "end should be 2 hours after start")
assert.Equal(t, 0.10, plan[0].Value, "first slot should have actual price")
assert.Equal(t, 0.10, plan[1].Value, "second slot should have actual price")
}
func TestContinuous_TargetAfterKnownPrices(t *testing.T) {
clock := clock.NewMock()
ctrl := gomock.NewController(t)
trf := api.NewMockTariff(ctrl)
trf.EXPECT().Rates().AnyTimes().Return(rates([]float64{0}, clock.Now(), time.Hour), nil)
p := &Planner{
log: util.NewLogger("foo"),
clock: clock,
tariff: trf,
}
plan := p.Plan(40*time.Minute, 0, clock.Now().Add(2*time.Hour), true) // 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, 0, clock.Now().Add(2*time.Hour), true)
assert.True(t, !SlotAt(clock.Now(), plan).IsZero(), "should start if car can not be charged completely after known prices ")
}
func TestContinuous_Precondition(t *testing.T) {
clock := clock.NewMock()
ctrl := gomock.NewController(t)
trf := api.NewMockTariff(ctrl)
trf.EXPECT().Rates().AnyTimes().Return(rates([]float64{1, 2, 3, 4}, clock.Now(), tariff.SlotDuration), nil)
p := &Planner{
log: util.NewLogger("foo"),
clock: clock,
tariff: trf,
}
plan := p.Plan(tariff.SlotDuration, tariff.SlotDuration, clock.Now().Add(4*tariff.SlotDuration), true)
assert.Equal(t, api.Rates{
{
Start: clock.Now().Add(3 * tariff.SlotDuration),
End: clock.Now().Add(4 * tariff.SlotDuration),
Value: 4,
},
}, plan, "expected last slot")
plan = p.Plan(2*tariff.SlotDuration, tariff.SlotDuration, clock.Now().Add(4*tariff.SlotDuration), true)
assert.Equal(t, api.Rates{
{
Start: clock.Now(),
End: clock.Now().Add(1 * tariff.SlotDuration),
Value: 1,
},
{
Start: clock.Now().Add(3 * tariff.SlotDuration),
End: clock.Now().Add(4 * tariff.SlotDuration),
Value: 4,
},
}, plan, "expected two slots")
plan = p.Plan(time.Duration(1.5*float64(tariff.SlotDuration)), tariff.SlotDuration, clock.Now().Add(4*tariff.SlotDuration), true)
assert.Equal(t, api.Rates{
{
Start: clock.Now(),
End: clock.Now().Add(time.Duration(0.5 * float64(tariff.SlotDuration))),
Value: 1,
},
{
Start: clock.Now().Add(3 * tariff.SlotDuration),
End: clock.Now().Add(4 * tariff.SlotDuration),
Value: 4,
},
}, plan, "expected trimmed slot at beginning and precondition slot")
plan = p.Plan(tariff.SlotDuration, 24*time.Hour, clock.Now().Add(time.Hour), true)
assert.Equal(t, api.Rates{
{
Start: clock.Now().Add(3 * tariff.SlotDuration),
End: clock.Now().Add(4 * tariff.SlotDuration),
Value: 4,
},
}, plan, "all precondition")
}
func TestContinuous_Precondition_NonSlotBoundary(t *testing.T) {
clock := clock.NewMock()
ctrl := gomock.NewController(t)
trf := api.NewMockTariff(ctrl)
slotDuration := 15 * time.Minute
// Create rates with 15-minute slots covering 8 hours (32 slots)
prices := make([]float64, 32)
for i := range prices {
prices[i] = float64(i + 1)
}
trf.EXPECT().Rates().AnyTimes().Return(rates(prices, clock.Now(), slotDuration), nil)
p := &Planner{
log: util.NewLogger("foo"),
clock: clock,
tariff: trf,
}
// Target time at 7:20 (non-slot boundary, between 7:15 and 7:30)
// 7:20 is 29 slots + 5 minutes from now
targetTime := clock.Now().Add(29*slotDuration + 5*time.Minute)
// 30 minutes preconditioning, 1 hour charging
precondition := 30 * time.Minute
requiredDuration := 1 * time.Hour
plan := p.Plan(requiredDuration, precondition, targetTime, true)
// Verify precondition ends exactly at target time
require.NotEmpty(t, plan)
lastSlot := plan[len(plan)-1]
assert.Equal(t, targetTime, lastSlot.End, "precondition should end exactly at target time")
// Calculate total precondition duration
var precondDuration time.Duration
// Precondition starts at targetTime - 30min = 6:50
precondStart := targetTime.Add(-precondition)
for _, slot := range plan {
if !slot.Start.Before(precondStart) {
precondDuration += slot.End.Sub(slot.Start)
}
}
assert.Equal(t, precondition, precondDuration, "total precondition duration should be exactly 30 minutes")
// In continuous mode, find cheapest continuous 30min window (after precondition reduction)
// Cheapest window: 01:00-01:30 (slots 0-1, prices 1+2)
// Precondition: 07:50-08:20 (exactly 30min before target at 08:20)
expectedPlan := api.Rates{
// Charging slots (cheapest continuous 30 minutes)
{Start: clock.Now(), End: clock.Now().Add(slotDuration), Value: 1},
{Start: clock.Now().Add(slotDuration), End: clock.Now().Add(2 * slotDuration), Value: 2},
// Precondition slots (exactly 30min before target, trimmed at both ends)
{Start: targetTime.Add(-precondition), End: clock.Now().Add(28 * slotDuration), Value: 28},
{Start: clock.Now().Add(28 * slotDuration), End: clock.Now().Add(29 * slotDuration), Value: 29},
{Start: clock.Now().Add(29 * slotDuration), End: targetTime, Value: 30},
}
assert.Equal(t, expectedPlan, plan, "expected continuous charging slots and trimmed precondition slots")
}
func TestPrecondition_Everything(t *testing.T) {
clock := clock.NewMock()
ctrl := gomock.NewController(t)
trf := api.NewMockTariff(ctrl)
// Create 8 hours of rates with varying prices (cheaper toward the end)
prices := []float64{10, 9, 8, 7, 6, 5, 4, 3}
trf.EXPECT().Rates().AnyTimes().Return(rates(prices, clock.Now(), tariff.SlotDuration), nil)
p := &Planner{
log: util.NewLogger("test"),
clock: clock,
tariff: trf,
}
targetTime := clock.Now().Add(8 * tariff.SlotDuration) // 8 hours from now
requiredDuration := 2 * tariff.SlotDuration // need 2 hours
precondition := 7 * 24 * time.Hour // "everything" = 7 days
// Test with continuous=false (cheapest mode - should be ignored)
plan := p.Plan(requiredDuration, precondition, targetTime, false)
require.NotEmpty(t, plan, "plan should not be empty")
// Plan should end exactly at target time
assert.Equal(t, targetTime, plan[len(plan)-1].End, "plan should end at target time")
// Plan should have total duration = requiredDuration (NOT precondition duration)
totalDuration := Duration(plan)
assert.Equal(t, requiredDuration, totalDuration, "plan duration should equal required duration, not precondition")
// Plan should start at latest possible time (targetTime - requiredDuration)
expectedStart := targetTime.Add(-requiredDuration)
assert.Equal(t, expectedStart, plan[0].Start, "plan should start at latest possible time")
// Should contain actual rate data (slots 6-7 with prices 4, 3)
assert.Len(t, plan, 2, "should have 2 slots for 30-minute duration")
assert.Equal(t, 4.0, plan[0].Value, "should have actual rate value from slot 6")
assert.Equal(t, 3.0, plan[1].Value, "should have actual rate value from slot 7")
// Test with continuous=true (should also be ignored when precondition=everything)
planContinuous := p.Plan(requiredDuration, precondition, targetTime, true)
assert.Equal(t, plan, planContinuous, "continuous flag should be ignored when precondition=everything")
}
func TestContinuous_ContinuousPlanNoTariff(t *testing.T) {
clock := clock.NewMock()
p := &Planner{
log: util.NewLogger("foo"),
clock: clock,
}
plan := p.Plan(time.Hour, 0, clock.Now(), true)
// single-slot plan
assert.Len(t, plan, 1)
assert.Equal(t, clock.Now(), SlotAt(clock.Now(), plan).Start)
assert.Equal(t, clock.Now().Add(time.Hour), SlotAt(clock.Now(), plan).End)
}
func TestContinuous_ContinuousPlan(t *testing.T) {
clock := clock.NewMock()
ctrl := gomock.NewController(t)
trf := api.NewMockTariff(ctrl)
trf.EXPECT().Rates().AnyTimes().Return(rates([]float64{0}, clock.Now().Add(time.Hour), time.Hour), nil)
p := &Planner{
log: util.NewLogger("foo"),
clock: clock,
tariff: trf,
}
plan := p.Plan(150*time.Minute, 0, clock.Now(), true)
// 3-slot plan
assert.Len(t, plan, 3)
}
func TestContinuous_ContinuousPlanOutsideRates(t *testing.T) {
clock := clock.NewMock()
ctrl := gomock.NewController(t)
trf := api.NewMockTariff(ctrl)
trf.EXPECT().Rates().AnyTimes().Return(rates([]float64{0}, clock.Now().Add(time.Hour), time.Hour), nil)
p := &Planner{
log: util.NewLogger("foo"),
clock: clock,
tariff: trf,
}
plan := p.Plan(30*time.Minute, 0, clock.Now(), true)
// 3-slot plan
assert.Len(t, plan, 1)
}
// TestContinuous_StartBeforeRates tests that when current time is before
// the first available rate, the planner waits and starts charging when
// rates become available, as long as there's enough time to reach the target
func TestContinuous_StartBeforeRates(t *testing.T) {
now := time.Date(1970, time.January, 1, 0, 0, 0, 0, time.UTC)
c := clock.NewMock()
c.Set(now)
ctrl := gomock.NewController(t)
log := util.NewLogger("test")
// Rates start 2 hours in the future (gap from now until first rate)
rates := api.Rates{
{Start: now.Add(2 * time.Hour), End: now.Add(3 * time.Hour), Value: 0.10},
{Start: now.Add(3 * time.Hour), End: now.Add(4 * time.Hour), Value: 0.15},
{Start: now.Add(4 * time.Hour), End: now.Add(5 * time.Hour), Value: 0.08}, // cheapest
{Start: now.Add(5 * time.Hour), End: now.Add(6 * time.Hour), Value: 0.20},
}
trf := api.NewMockTariff(ctrl)
trf.EXPECT().Rates().AnyTimes().Return(rates, nil)
planner := &Planner{
log: log,
clock: c,
tariff: trf,
}
targetTime := now.Add(6 * time.Hour)
requiredDuration := time.Hour
plan := planner.Plan(requiredDuration, 0, targetTime, true) // continuous mode
require.NotEmpty(t, plan, "plan should not be empty")
require.Len(t, plan, 1, "should create single slot with actual price")
// Should wait until rates are available and pick the cheapest slot
assert.Equal(t, now.Add(4*time.Hour), plan[0].Start, "should start at cheapest available rate")
assert.Equal(t, now.Add(5*time.Hour), plan[0].End, "should end after required duration")
assert.Equal(t, 0.08, plan[0].Value, "should have actual price from cheapest slot")
// Plan must not start before rates are available
assert.False(t, plan[0].Start.Before(rates[0].Start), "plan must not start before first available rate")
}
// TestContinuous_StartBeforeRatesInsufficientTime tests that when current time
// is before the first available rate AND there's not enough time after rates
// start to complete charging before target, the planner starts charging as soon
// as rates become available (best effort approach)
func TestContinuous_StartBeforeRatesInsufficientTime(t *testing.T) {
now := time.Date(1970, time.January, 1, 0, 0, 0, 0, time.UTC)
c := clock.NewMock()
c.Set(now)
ctrl := gomock.NewController(t)
log := util.NewLogger("test")
// Rates start 2 hours in the future, but we need 3 hours to charge
// and target is only 4 hours away (not enough time to fully charge)
rates := api.Rates{
{Start: now.Add(2 * time.Hour), End: now.Add(3 * time.Hour), Value: 0.10},
{Start: now.Add(3 * time.Hour), End: now.Add(4 * time.Hour), Value: 0.15},
}
trf := api.NewMockTariff(ctrl)
trf.EXPECT().Rates().AnyTimes().Return(rates, nil)
planner := &Planner{
log: log,
clock: c,
tariff: trf,
}
targetTime := now.Add(4 * time.Hour)
requiredDuration := 3 * time.Hour // Need 3h but only 2h available after rates start
plan := planner.Plan(requiredDuration, 0, targetTime, true) // continuous mode
require.NotEmpty(t, plan, "plan should not be empty")
// Best effort: start immediately to maximize charging time
assert.Equal(t, now, plan[0].Start, "should start immediately")
assert.Equal(t, 0.0, plan[0].Value, "gap-filling slot before rates has no price")
}
// TestContinuous_StartBeforeRatesSufficientTime tests that when current time
// is before the first available rate AND there IS enough time to complete
// charging, the planner finds the cheapest continuous window
func TestContinuous_StartBeforeRatesSufficientTime(t *testing.T) {
now := time.Date(1970, time.January, 1, 0, 0, 0, 0, time.UTC)
c := clock.NewMock()
c.Set(now)
ctrl := gomock.NewController(t)
log := util.NewLogger("test")
// Rates start 2 hours in the future, we need 2 hours to charge
// and target is 8 hours away (enough time to optimize)
rates := api.Rates{
{Start: now.Add(2 * time.Hour), End: now.Add(3 * time.Hour), Value: 0.20},
{Start: now.Add(3 * time.Hour), End: now.Add(4 * time.Hour), Value: 0.15},
{Start: now.Add(4 * time.Hour), End: now.Add(5 * time.Hour), Value: 0.10}, // cheapest
{Start: now.Add(5 * time.Hour), End: now.Add(6 * time.Hour), Value: 0.08}, // cheapest
{Start: now.Add(6 * time.Hour), End: now.Add(7 * time.Hour), Value: 0.12},
{Start: now.Add(7 * time.Hour), End: now.Add(8 * time.Hour), Value: 0.25},
}
trf := api.NewMockTariff(ctrl)
trf.EXPECT().Rates().AnyTimes().Return(rates, nil)
planner := &Planner{
log: log,
clock: c,
tariff: trf,
}
targetTime := now.Add(8 * time.Hour)
requiredDuration := 2 * time.Hour
plan := planner.Plan(requiredDuration, 0, targetTime, true) // continuous mode
require.NotEmpty(t, plan, "plan should not be empty")
require.Len(t, plan, 2, "should find 2-hour continuous window")
// Should find cheapest continuous 2-hour window (04:00-06:00)
assert.Equal(t, now.Add(4*time.Hour), plan[0].Start, "should start at cheapest window")
assert.Equal(t, 0.10, plan[0].Value, "first slot should have cheapest window price")
assert.Equal(t, 0.08, plan[1].Value, "second slot should have cheapest window price")
}
// the target time (even at non-slot boundaries) by starting early
func TestContinuous_ExcessTimeFinishesAtTarget(t *testing.T) {
now := time.Date(1970, time.January, 1, 0, 0, 0, 0, time.UTC)
c := clock.NewMock()
c.Set(now)
ctrl := gomock.NewController(t)
log := util.NewLogger("test")
slotDuration := 15 * time.Minute
// Create 20 slots of 15 minutes each (5 hours total)
// Prices: cheaper in the middle slots
prices := []float64{
0.30, 0.30, 0.30, 0.30, // 00:00-01:00 expensive
0.15, 0.10, 0.10, 0.10, // 01:00-02:00 medium+cheap
0.08, 0.08, 0.08, 0.08, // 02:00-03:00 cheapest
0.12, 0.12, 0.12, 0.12, // 03:00-04:00 medium
0.20, 0.20, 0.20, 0.20, // 04:00-05:00 expensive
}
trf := api.NewMockTariff(ctrl)
trf.EXPECT().Rates().AnyTimes().Return(rates(prices, now, slotDuration), nil)
planner := &Planner{
log: log,
clock: c,
tariff: trf,
}
// Target at 03:10 (non-slot boundary - 10 minutes into the 03:00-03:15 slot)
targetTime := now.Add(3*time.Hour + 10*time.Minute)
requiredDuration := 2*time.Hour + 5*time.Minute // need 2h5m, have 3h10m available
plan := planner.Plan(requiredDuration, 0, targetTime, true) // continuous, no precondition
require.NotEmpty(t, plan)
// Plan must not extend beyond target
lastSlot := plan[len(plan)-1]
assert.False(t, lastSlot.End.After(targetTime),
"plan must not extend beyond target")
// Total duration must equal required duration
assert.Equal(t, requiredDuration, Duration(plan), "plan duration must match required")
// Plan should use the cheapest slots (02:00-03:00 range, prices 0.08)
avgCost := AverageCost(plan)
assert.Less(t, avgCost, 0.12, "plan should use cheaper slots")
// Target at 03:10 (non-slot boundary - must finish before target)
requiredDurationShort := 12 * time.Minute // need 12m, have 3h10m available
plan = planner.Plan(requiredDurationShort, 0, targetTime, true) // continuous, no precondition
require.NotEmpty(t, plan)
// Plan must not extend beyond target
lastSlotShort := plan[len(plan)-1]
assert.False(t, lastSlotShort.End.After(targetTime),
"plan must not extend beyond target")
// Total duration must equal required duration
assert.Equal(t, requiredDurationShort, Duration(plan), "plan (short) duration must match required")
// Plan should use the cheapest slots
avgCostShort := AverageCost(plan)
assert.Equal(t, 0.08, avgCostShort, "plan (short) should use cheapest slots (0.08)")
// Target at 03:10 (non-slot boundary - must finish before target)
requiredDurationMedium := 27 * time.Minute // need 27m, have 3h10m available
plan = planner.Plan(requiredDurationMedium, 0, targetTime, true) // continuous, no precondition
require.NotEmpty(t, plan)
// Plan must not extend beyond target
lastSlotMedium := plan[len(plan)-1]
assert.False(t, lastSlotMedium.End.After(targetTime),
"plan must not extend beyond target")
// Total duration must equal required duration
assert.Equal(t, requiredDurationMedium, Duration(plan), "plan (medium) duration must match required")
// Plan should use the cheapest slots
avgCostMedium := AverageCost(plan)
assert.Equal(t, 0.08, avgCostMedium, "plan (medium) should use cheapest slots (0.08)")
}

View file

@ -7,6 +7,7 @@ import (
"github.com/benbjohnson/clock" "github.com/benbjohnson/clock"
"github.com/evcc-io/evcc/api" "github.com/evcc-io/evcc/api"
"github.com/evcc-io/evcc/tariff"
"github.com/evcc-io/evcc/util" "github.com/evcc-io/evcc/util"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
@ -17,7 +18,7 @@ func rates(prices []float64, start time.Time, slotDuration time.Duration) api.Ra
res := make(api.Rates, 0, len(prices)) res := make(api.Rates, 0, len(prices))
for i, v := range prices { for i, v := range prices {
slotStart := start.Add(time.Duration(i) * time.Hour) slotStart := start.Add(time.Duration(i) * slotDuration)
ar := api.Rate{ ar := api.Rate{
Start: slotStart, Start: slotStart,
End: slotStart.Add(slotDuration), End: slotStart.Add(slotDuration),
@ -29,15 +30,16 @@ func rates(prices []float64, start time.Time, slotDuration time.Duration) api.Ra
return res return res
} }
// func dumpRates(rr api.Rates) string { func TestClampRates(t *testing.T) {
// var b []byte clock := clock.NewMock()
// for _, r := range rr { rr := rates([]float64{0, 1}, clock.Now(), time.Hour)
// b = fmt.Appendf(b, "%+v\n", r)
// }
// return string(b)
// }
// TODO start before start of rates assert.Equal(t, rr, clampRates(rr, clock.Now(), clock.Now().Add(2*time.Hour)))
assert.Equal(t, rates([]float64{0}, clock.Now(), time.Hour), clampRates(rr, clock.Now(), clock.Now().Add(time.Hour)))
exp := api.Rates{{Start: clock.Now().Add(time.Hour), End: clock.Now().Add(2 * time.Hour), Value: 1}}
assert.Equal(t, exp, clampRates(rr, clock.Now().Add(time.Hour), clock.Now().Add(2*time.Hour)))
}
func TestPlan(t *testing.T) { func TestPlan(t *testing.T) {
clock := clock.NewMock() clock := clock.NewMock()
@ -57,7 +59,8 @@ func TestPlan(t *testing.T) {
slices.SortStableFunc(rates, sortByCost) slices.SortStableFunc(rates, sortByCost)
{ {
plan := p.plan(rates, time.Hour, clock.Now()) // filter rates to [now, now] window - should return empty
plan := p.plan(clampRates(rates, clock.Now(), clock.Now()), time.Hour, clock.Now())
assert.Empty(t, plan) assert.Empty(t, plan)
} }
@ -125,7 +128,8 @@ func TestPlan(t *testing.T) {
for i, tc := range tc { for i, tc := range tc {
t.Log(tc.desc) t.Log(tc.desc)
clock.Set(tc.now) clock.Set(tc.now)
plan := p.plan(rates, tc.duration, tc.target) // filter rates to [now, target] window as caller would do
plan := p.plan(clampRates(rates, tc.now, tc.target), tc.duration, tc.target)
assert.Equalf(t, tc.planStart.UTC(), Start(plan).UTC(), "case %d start", i) assert.Equalf(t, tc.planStart.UTC(), Start(plan).UTC(), "case %d start", i)
assert.Equalf(t, tc.duration, Duration(plan), "case %d duration", i) assert.Equalf(t, tc.duration, Duration(plan), "case %d duration", i)
@ -141,7 +145,7 @@ func TestNilTariff(t *testing.T) {
clock: clock, clock: clock,
} }
plan := p.Plan(time.Hour, 0, clock.Now().Add(30*time.Minute)) plan := p.Plan(time.Hour, 0, clock.Now().Add(30*time.Minute), false)
assert.Equal(t, api.Rates{ assert.Equal(t, api.Rates{
{ {
Start: clock.Now(), Start: clock.Now(),
@ -163,7 +167,7 @@ func TestRatesError(t *testing.T) {
tariff: trf, tariff: trf,
} }
plan := p.Plan(time.Hour, 0, clock.Now().Add(30*time.Minute)) plan := p.Plan(time.Hour, 0, clock.Now().Add(30*time.Minute), false)
assert.Equal(t, api.Rates{ assert.Equal(t, api.Rates{
{ {
Start: clock.Now(), Start: clock.Now(),
@ -192,10 +196,10 @@ func TestFlatTariffTargetInThePast(t *testing.T) {
}, },
} }
plan := p.Plan(time.Hour, 0, clock.Now().Add(30*time.Minute)) plan := p.Plan(time.Hour, 0, clock.Now().Add(30*time.Minute), false)
assert.Equal(t, simplePlan, plan, "expected simple plan") assert.Equal(t, simplePlan, plan, "expected simple plan")
plan = p.Plan(time.Hour, 0, clock.Now().Add(-30*time.Minute)) plan = p.Plan(time.Hour, 0, clock.Now().Add(-30*time.Minute), false)
assert.Equal(t, simplePlan, plan, "expected simple plan") assert.Equal(t, simplePlan, plan, "expected simple plan")
} }
@ -216,12 +220,12 @@ func TestFlatTariffLongSlots(t *testing.T) {
// that slots are not longer than 1 hour and with that context this is not a problem // that slots are not longer than 1 hour and with that context this is not a problem
// expect 00:00-01:00 UTC // expect 00:00-01:00 UTC
plan := p.Plan(time.Hour, 0, clock.Now().Add(2*time.Hour)) plan := p.Plan(time.Hour, 0, clock.Now().Add(2*time.Hour), false)
assert.Equal(t, api.Rate{Start: clock.Now(), End: clock.Now().Add(time.Hour)}, SlotAt(clock.Now(), plan)) 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)) assert.Equal(t, api.Rate{}, SlotAt(clock.Now().Add(time.Hour), plan))
// expect 00:00-01:00 UTC // expect 00:00-01:00 UTC
plan = p.Plan(time.Hour, 0, clock.Now().Add(time.Hour)) plan = p.Plan(time.Hour, 0, clock.Now().Add(time.Hour), false)
assert.Equal(t, api.Rate{Start: clock.Now(), End: clock.Now().Add(time.Hour)}, SlotAt(clock.Now(), plan)) assert.Equal(t, api.Rate{Start: clock.Now(), End: clock.Now().Add(time.Hour)}, SlotAt(clock.Now(), plan))
} }
@ -238,10 +242,10 @@ func TestTargetAfterKnownPrices(t *testing.T) {
tariff: trf, tariff: trf,
} }
plan := p.Plan(40*time.Minute, 0, 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), false) // 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 ") 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, 0, clock.Now().Add(2*time.Hour)) plan = p.Plan(2*time.Hour, 0, clock.Now().Add(2*time.Hour), false)
assert.True(t, !SlotAt(clock.Now(), plan).IsZero(), "should start if car can not be charged completely after known prices ") assert.True(t, !SlotAt(clock.Now(), plan).IsZero(), "should start if car can not be charged completely after known prices ")
} }
@ -265,19 +269,18 @@ func TestChargeAfterTargetTime(t *testing.T) {
}, },
} }
plan := p.Plan(time.Hour, 0, clock.Now()) plan := p.Plan(time.Hour, 0, clock.Now(), false)
assert.Equal(t, simplePlan, plan, "expected simple plan") assert.Equal(t, simplePlan, plan, "expected simple plan")
plan = p.Plan(time.Hour, 0, clock.Now().Add(-time.Hour)) plan = p.Plan(time.Hour, 0, clock.Now().Add(-time.Hour), false)
assert.Equal(t, simplePlan, plan, "expected simple plan") assert.Equal(t, simplePlan, plan, "expected simple plan")
} }
func TestPrecondition(t *testing.T) { func TestPrecondition(t *testing.T) {
clock := clock.NewMock() clock := clock.NewMock()
ctrl := gomock.NewController(t) ctrl := gomock.NewController(t)
trf := api.NewMockTariff(ctrl) trf := api.NewMockTariff(ctrl)
trf.EXPECT().Rates().AnyTimes().Return(rates([]float64{1, 2, 3, 4}, clock.Now(), time.Hour), nil) trf.EXPECT().Rates().AnyTimes().Return(rates([]float64{1, 2, 3, 4}, clock.Now(), tariff.SlotDuration), nil)
p := &Planner{ p := &Planner{
log: util.NewLogger("foo"), log: util.NewLogger("foo"),
@ -285,54 +288,120 @@ func TestPrecondition(t *testing.T) {
tariff: trf, tariff: trf,
} }
plan := p.Plan(time.Hour, time.Hour, clock.Now().Add(4*time.Hour)) plan := p.Plan(tariff.SlotDuration, tariff.SlotDuration, clock.Now().Add(4*tariff.SlotDuration), false)
assert.Equal(t, api.Rates{ assert.Equal(t, api.Rates{
{ {
Start: clock.Now().Add(3 * time.Hour), Start: clock.Now().Add(3 * tariff.SlotDuration),
End: clock.Now().Add(4 * time.Hour), End: clock.Now().Add(4 * tariff.SlotDuration),
Value: 4, Value: 4,
}, },
}, plan, "expected last slot") }, plan, "expected last slot")
// NOTE: with 15min slots we no longer expect late start of the slot plan = p.Plan(2*tariff.SlotDuration, tariff.SlotDuration, clock.Now().Add(4*tariff.SlotDuration), false)
plan = p.Plan(2*time.Hour, time.Hour, clock.Now().Add(4*time.Hour))
assert.Equal(t, api.Rates{ assert.Equal(t, api.Rates{
{ {
Start: clock.Now(), Start: clock.Now(),
End: clock.Now().Add(1 * time.Hour), End: clock.Now().Add(1 * tariff.SlotDuration),
Value: 1, Value: 1,
}, },
{ {
Start: clock.Now().Add(3 * time.Hour), Start: clock.Now().Add(3 * tariff.SlotDuration),
End: clock.Now().Add(4 * time.Hour), End: clock.Now().Add(4 * tariff.SlotDuration),
Value: 4, Value: 4,
}, },
}, plan, "expected two slots") }, plan, "expected two slots")
plan = p.Plan(time.Hour, 30*time.Minute, clock.Now().Add(4*time.Hour)) plan = p.Plan(time.Duration(1.5*float64(tariff.SlotDuration)), tariff.SlotDuration, clock.Now().Add(4*tariff.SlotDuration), false)
assert.Equal(t, api.Rates{ assert.Equal(t, api.Rates{
{ {
Start: clock.Now(), Start: clock.Now(),
End: clock.Now().Add(30 * time.Minute), End: clock.Now().Add(time.Duration(0.5 * float64(tariff.SlotDuration))),
Value: 1, Value: 1,
}, },
{ {
Start: clock.Now().Add(210 * time.Minute), // 3.5h Start: clock.Now().Add(3 * tariff.SlotDuration),
End: clock.Now().Add(4 * time.Hour), // 4.0h End: clock.Now().Add(4 * tariff.SlotDuration),
Value: 4, Value: 4,
}, },
}, plan, "expected short early and split late slot") }, plan, "expected trimmed slot at beginning and precondition slot")
plan = p.Plan(time.Hour, 24*time.Hour, clock.Now().Add(4*time.Hour)) plan = p.Plan(tariff.SlotDuration, 24*time.Hour, clock.Now().Add(time.Hour), false)
assert.Equal(t, api.Rates{ assert.Equal(t, api.Rates{
{ {
Start: clock.Now().Add(3 * time.Hour), Start: clock.Now().Add(3 * tariff.SlotDuration),
End: clock.Now().Add(4 * time.Hour), End: clock.Now().Add(4 * tariff.SlotDuration),
Value: 4, Value: 4,
}, },
}, plan, "all precondition") }, plan, "all precondition")
} }
func TestPrecondition_NonSlotBoundary(t *testing.T) {
clock := clock.NewMock()
ctrl := gomock.NewController(t)
trf := api.NewMockTariff(ctrl)
slotDuration := 15 * time.Minute
// Create rates with 15-minute slots covering 8 hours (32 slots)
prices := make([]float64, 32)
for i := range prices {
prices[i] = float64(i + 1)
}
trf.EXPECT().Rates().AnyTimes().Return(rates(prices, clock.Now(), slotDuration), nil)
p := &Planner{
log: util.NewLogger("foo"),
clock: clock,
tariff: trf,
}
// Target time at 7:20 (non-slot boundary, between 7:15 and 7:30)
// 7:20 is 29 slots + 5 minutes from now
targetTime := clock.Now().Add(29*slotDuration + 5*time.Minute)
// 30 minutes preconditioning, 1 hour charging
precondition := 30 * time.Minute
requiredDuration := 1 * time.Hour
plan := p.Plan(requiredDuration, precondition, targetTime, false)
// Verify precondition ends exactly at target time
require.NotEmpty(t, plan)
lastSlot := plan[len(plan)-1]
assert.Equal(t, targetTime, lastSlot.End, "precondition should end exactly at target time")
// Calculate total precondition duration
var precondDuration time.Duration
// Precondition starts at targetTime - 30min = 6:50
precondStart := targetTime.Add(-precondition)
for _, slot := range plan {
if !slot.Start.Before(precondStart) {
precondDuration += slot.End.Sub(slot.Start)
}
}
assert.Equal(t, precondition, precondDuration, "total precondition duration should be exactly 30 minutes")
// Verify expected slots structure
// Note: precondition (30min) reduces effective required duration from 1h to 30min
// Cheapest 30min charging: slots at 01:00-01:30 (slots 0-1, prices 1,2)
// Precondition: 07:50-08:20 (exactly 30min before target at 08:20)
// - 07:45-08:00 (slot 27, price 28) -> trimmed to 07:50-08:00 (10min)
// - 08:00-08:15 (slot 28, price 29) -> full slot (15min)
// - 08:15-08:30 (slot 29, price 30) -> trimmed to 08:15-08:20 (5min)
expectedPlan := api.Rates{
// Charging slots (cheapest 30 minutes after precondition reduction)
{Start: clock.Now(), End: clock.Now().Add(slotDuration), Value: 1},
{Start: clock.Now().Add(slotDuration), End: clock.Now().Add(2 * slotDuration), Value: 2},
// Precondition slots (exactly 30min before target, trimmed at both ends)
{Start: targetTime.Add(-precondition), End: clock.Now().Add(28 * slotDuration), Value: 28},
{Start: clock.Now().Add(28 * slotDuration), End: clock.Now().Add(29 * slotDuration), Value: 29},
{Start: clock.Now().Add(29 * slotDuration), End: targetTime, Value: 30},
}
assert.Equal(t, expectedPlan, plan, "expected charging slots and trimmed precondition slots")
}
func TestContinuousPlanNoTariff(t *testing.T) { func TestContinuousPlanNoTariff(t *testing.T) {
clock := clock.NewMock() clock := clock.NewMock()
@ -341,7 +410,7 @@ func TestContinuousPlanNoTariff(t *testing.T) {
clock: clock, clock: clock,
} }
plan := p.Plan(time.Hour, 0, clock.Now()) plan := p.Plan(time.Hour, 0, clock.Now(), false)
// single-slot plan // single-slot plan
assert.Len(t, plan, 1) assert.Len(t, plan, 1)
@ -362,7 +431,7 @@ func TestContinuousPlan(t *testing.T) {
tariff: trf, tariff: trf,
} }
plan := p.Plan(150*time.Minute, 0, clock.Now()) plan := p.Plan(150*time.Minute, 0, clock.Now(), false)
// 3-slot plan // 3-slot plan
assert.Len(t, plan, 3) assert.Len(t, plan, 3)
@ -381,8 +450,93 @@ func TestContinuousPlanOutsideRates(t *testing.T) {
tariff: trf, tariff: trf,
} }
plan := p.Plan(30*time.Minute, 0, clock.Now()) plan := p.Plan(30*time.Minute, 0, clock.Now(), false)
// 3-slot plan // 3-slot plan
assert.Len(t, plan, 1) assert.Len(t, plan, 1)
} }
// TestStartBeforeRates tests that when current time is before
// the first available rate, the planner waits and starts charging when
// rates become available, as long as there's enough time to reach the target
func TestStartBeforeRates(t *testing.T) {
now := time.Date(1970, time.January, 1, 0, 0, 0, 0, time.UTC)
c := clock.NewMock()
c.Set(now)
ctrl := gomock.NewController(t)
log := util.NewLogger("test")
// Rates start 2 hours in the future (gap from now until first rate)
rates := api.Rates{
{Start: now.Add(2 * time.Hour), End: now.Add(3 * time.Hour), Value: 0.10},
{Start: now.Add(3 * time.Hour), End: now.Add(4 * time.Hour), Value: 0.15},
{Start: now.Add(4 * time.Hour), End: now.Add(5 * time.Hour), Value: 0.08}, // cheapest
{Start: now.Add(5 * time.Hour), End: now.Add(6 * time.Hour), Value: 0.20},
}
trf := api.NewMockTariff(ctrl)
trf.EXPECT().Rates().AnyTimes().Return(rates, nil)
planner := &Planner{
log: log,
clock: c,
tariff: trf,
}
targetTime := now.Add(6 * time.Hour)
requiredDuration := time.Hour
plan := planner.Plan(requiredDuration, 0, targetTime, true) // continuous mode
require.NotEmpty(t, plan, "plan should not be empty")
require.Len(t, plan, 1, "should create single slot with actual price")
// Should wait until rates are available and pick the cheapest slot
assert.Equal(t, now.Add(4*time.Hour), plan[0].Start, "should start at cheapest available rate")
assert.Equal(t, now.Add(5*time.Hour), plan[0].End, "should end after required duration")
assert.Equal(t, 0.08, plan[0].Value, "should have actual price from cheapest slot")
// Plan must not start before rates are available
assert.False(t, plan[0].Start.Before(rates[0].Start), "plan must not start before first available rate")
}
// TestStartBeforeRatesInsufficientTime tests that when current time
// is before the first available rate AND there's not enough time after rates
// start to complete charging before target, the planner starts charging as soon
// as rates become available (best effort approach)
func TestStartBeforeRatesInsufficientTime(t *testing.T) {
now := time.Date(1970, time.January, 1, 0, 0, 0, 0, time.UTC)
c := clock.NewMock()
c.Set(now)
ctrl := gomock.NewController(t)
log := util.NewLogger("test")
// Rates start 2 hours in the future, but we need 3 hours to charge
// and target is only 4 hours away (not enough time to fully charge)
rates := api.Rates{
{Start: now.Add(2 * time.Hour), End: now.Add(3 * time.Hour), Value: 0.10},
{Start: now.Add(3 * time.Hour), End: now.Add(4 * time.Hour), Value: 0.15},
}
trf := api.NewMockTariff(ctrl)
trf.EXPECT().Rates().AnyTimes().Return(rates, nil)
planner := &Planner{
log: log,
clock: c,
tariff: trf,
}
targetTime := now.Add(4 * time.Hour)
requiredDuration := 3 * time.Hour // Need 3h but only 2h available after rates start
plan := planner.Plan(requiredDuration, 0, targetTime, false) // dispersed mode
require.NotEmpty(t, plan, "plan should not be empty - starts when rates become available")
// Best effort: start as soon as rates are available
assert.Equal(t, now.Add(2*time.Hour), plan[0].Start, "should start at first available rate")
assert.Equal(t, 0.10, plan[0].Value, "should use first available rate price")
}

View file

@ -14,24 +14,26 @@ import (
type planStruct struct { type planStruct struct {
Soc int `json:"soc"` Soc int `json:"soc"`
Continuous bool `json:"continuous"`
Precondition int64 `json:"precondition"` Precondition int64 `json:"precondition"`
Time time.Time `json:"time"` Time time.Time `json:"time"`
} }
type vehicleStruct struct { type vehicleStruct struct {
Title string `json:"title"` Title string `json:"title"`
Icon string `json:"icon,omitempty"` Icon string `json:"icon,omitempty"`
Capacity float64 `json:"capacity,omitempty"` Capacity float64 `json:"capacity,omitempty"`
Phases int `json:"phases,omitempty"` Phases int `json:"phases,omitempty"`
MinSoc int `json:"minSoc,omitempty"` MinSoc int `json:"minSoc,omitempty"`
LimitSoc int `json:"limitSoc,omitempty"` LimitSoc int `json:"limitSoc,omitempty"`
MinCurrent float64 `json:"minCurrent,omitempty"` MinCurrent float64 `json:"minCurrent,omitempty"`
MaxCurrent float64 `json:"maxCurrent,omitempty"` MaxCurrent float64 `json:"maxCurrent,omitempty"`
MaxPower float64 `json:"maxPower,omitempty"` Priority int `json:"priority,omitempty"`
Priority int `json:"priority,omitempty"` Features []string `json:"features,omitempty"`
Features []string `json:"features,omitempty"` Plan *planStruct `json:"plan,omitempty"`
Plan *planStruct `json:"plan,omitempty"` RepeatingPlans []api.RepeatingPlan `json:"repeatingPlans"`
RepeatingPlans []api.RepeatingPlan `json:"repeatingPlans"` PlanPrecondition int64 `json:"planPrecondition"`
PlanContinuous bool `json:"planContinuous"`
} }
// publishVehicles returns a list of vehicle titles // publishVehicles returns a list of vehicle titles
@ -46,30 +48,32 @@ func (site *Site) publishVehicles() {
} }
ac := instance.OnIdentified() ac := instance.OnIdentified()
strategy := v.GetPlanStrategy()
var plan *planStruct var plan *planStruct
if time, precondition, soc := v.GetPlanSoc(); !time.IsZero() { if time, soc := v.GetPlanSoc(); !time.IsZero() {
plan = &planStruct{Soc: soc, Precondition: int64(precondition.Seconds()), Time: time} plan = &planStruct{Soc: soc, Precondition: int64(strategy.Precondition.Seconds()), Time: time}
} }
res[v.Name()] = vehicleStruct{ res[v.Name()] = vehicleStruct{
Title: instance.GetTitle(), Title: instance.GetTitle(),
Icon: instance.Icon(), Icon: instance.Icon(),
Capacity: instance.Capacity(), Capacity: instance.Capacity(),
Phases: instance.Phases(), Phases: instance.Phases(),
MinSoc: v.GetMinSoc(), MinSoc: v.GetMinSoc(),
LimitSoc: v.GetLimitSoc(), LimitSoc: v.GetLimitSoc(),
MinCurrent: ac.MinCurrent, MinCurrent: ac.MinCurrent,
MaxCurrent: ac.MaxCurrent, MaxCurrent: ac.MaxCurrent,
MaxPower: ac.MaxPower, Priority: ac.Priority,
Priority: ac.Priority, Features: lo.Map(instance.Features(), func(f api.Feature, _ int) string { return f.String() }),
Features: lo.Map(instance.Features(), func(f api.Feature, _ int) string { return f.String() }), Plan: plan,
Plan: plan, RepeatingPlans: v.GetRepeatingPlans(),
RepeatingPlans: v.GetRepeatingPlans(), PlanPrecondition: int64(strategy.Precondition.Seconds()),
PlanContinuous: strategy.Continuous,
} }
if lp := site.coordinator.Owner(instance); lp != nil { if lp := site.coordinator.Owner(instance); lp != nil {
lp.PublishEffectiveValues() go lp.PublishEffectiveValues()
} }
} }

View file

@ -71,24 +71,20 @@ func (v *adapter) SetLimitSoc(soc int) {
} }
// GetPlanSoc returns the charge plan soc // GetPlanSoc returns the charge plan soc
func (v *adapter) GetPlanSoc() (time.Time, time.Duration, int) { func (v *adapter) GetPlanSoc() (time.Time, int) {
var ts time.Time var ts time.Time
if v, err := settings.Time(v.key() + keys.PlanTime); err == nil { if v, err := settings.Time(v.key() + keys.PlanTime); err == nil {
ts = v 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 var soc int
if v, err := settings.Int(v.key() + keys.PlanSoc); err == nil { if v, err := settings.Int(v.key() + keys.PlanSoc); err == nil {
soc = int(v) soc = int(v)
} }
return ts, precondition, soc return ts, soc
} }
// SetPlanSoc sets the charge plan soc // SetPlanSoc sets the charge plan soc
func (v *adapter) SetPlanSoc(ts time.Time, precondition time.Duration, soc int) error { func (v *adapter) SetPlanSoc(ts time.Time, soc int) error {
if !ts.IsZero() && ts.Before(time.Now()) { if !ts.IsZero() && ts.Before(time.Now()) {
return errors.New("timestamp is in the past") return errors.New("timestamp is in the past")
} }
@ -98,11 +94,10 @@ func (v *adapter) SetPlanSoc(ts time.Time, precondition time.Duration, soc int)
ts = time.Time{} ts = time.Time{}
v.log.DEBUG.Printf("delete %s plan", v.name) v.log.DEBUG.Printf("delete %s plan", v.name)
} else { } else {
v.log.DEBUG.Printf("set %s plan soc: %d @ %v (precondition: %v)", v.name, soc, ts.Round(time.Second).Local(), precondition) v.log.DEBUG.Printf("set %s plan soc: %d @ %v", v.name, soc, ts.Round(time.Second).Local())
} }
settings.SetTime(v.key()+keys.PlanTime, ts) settings.SetTime(v.key()+keys.PlanTime, ts)
settings.SetInt(v.key()+keys.PlanPrecondition, int64(precondition.Seconds()))
settings.SetInt(v.key()+keys.PlanSoc, int64(soc)) settings.SetInt(v.key()+keys.PlanSoc, int64(soc))
v.publish() v.publish()
@ -125,10 +120,11 @@ func (v *adapter) SetRepeatingPlans(plans []api.RepeatingPlan) error {
} }
} }
if err := settings.SetJson(v.key()+keys.RepeatingPlans, plans); err != nil {
return err
}
v.log.DEBUG.Printf("update repeating plans for %s to: %v", v.name, plans) v.log.DEBUG.Printf("update repeating plans for %s to: %v", v.name, plans)
settings.SetJson(v.key()+keys.RepeatingPlans, plans)
v.publish() v.publish()
return nil return nil
@ -143,3 +139,22 @@ func (v *adapter) GetRepeatingPlans() []api.RepeatingPlan {
return plans return plans
} }
func (v *adapter) GetPlanStrategy() api.PlanStrategy {
var strategy api.PlanStrategy
if err := settings.Json(v.key()+keys.PlanStrategy, &strategy); err != nil {
return api.PlanStrategy{}
}
return strategy
}
func (v *adapter) SetPlanStrategy(planStrategy api.PlanStrategy) error {
if err := settings.SetJson(v.key()+keys.PlanStrategy, planStrategy); err != nil {
return err
}
v.log.DEBUG.Printf("update plan strategy for vehicle %s (precondition: %vs, continuous: %v)", v.name, planStrategy.Continuous, planStrategy.Precondition)
v.publish()
return nil
}

View file

@ -39,15 +39,20 @@ type API interface {
SetLimitSoc(soc int) SetLimitSoc(soc int)
// GetPlanSoc returns the charge plan soc // GetPlanSoc returns the charge plan soc
GetPlanSoc() (time.Time, time.Duration, int) GetPlanSoc() (time.Time, int)
// SetPlanSoc sets the charge plan time and soc // SetPlanSoc sets the charge plan time and soc
SetPlanSoc(time.Time, time.Duration, int) error SetPlanSoc(time.Time, int) error
// GetRepeatingPlans returns every repeating plan // GetRepeatingPlans returns every repeating plan
GetRepeatingPlans() []api.RepeatingPlan GetRepeatingPlans() []api.RepeatingPlan
// SetRepeatingPlans stores every repeating plan // SetRepeatingPlans stores every repeating plan
SetRepeatingPlans([]api.RepeatingPlan) error SetRepeatingPlans([]api.RepeatingPlan) error
// GetPlanStrategy returns the plan strategy
GetPlanStrategy() api.PlanStrategy
// SetPlanStrategy sets the plan strategy
SetPlanStrategy(api.PlanStrategy) error
// // GetMinCurrent returns the min charging current // // GetMinCurrent returns the min charging current
// GetMinCurrent() float64 // GetMinCurrent() float64
// // SetMinCurrent sets the min charging current // // SetMinCurrent sets the min charging current

View file

@ -39,12 +39,12 @@ func (v *dummy) SetLimitSoc(soc int) {
} }
// GetPlanSoc returns the charge plan soc // GetPlanSoc returns the charge plan soc
func (v *dummy) GetPlanSoc() (time.Time, time.Duration, int) { func (v *dummy) GetPlanSoc() (time.Time, int) {
return time.Time{}, 0, 0 return time.Time{}, 0
} }
// SetPlanSoc sets the charge plan soc // SetPlanSoc sets the charge plan soc
func (v *dummy) SetPlanSoc(ts time.Time, precondition time.Duration, soc int) error { func (v *dummy) SetPlanSoc(ts time.Time, soc int) error {
return nil return nil
} }
@ -56,3 +56,11 @@ func (v *dummy) SetRepeatingPlans(plans []api.RepeatingPlan) error {
func (v *dummy) GetRepeatingPlans() []api.RepeatingPlan { func (v *dummy) GetRepeatingPlans() []api.RepeatingPlan {
return nil return nil
} }
func (v *dummy) GetPlanStrategy() api.PlanStrategy {
return api.PlanStrategy{}
}
func (v *dummy) SetPlanStrategy(strategy api.PlanStrategy) error {
return nil
}

View file

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

View file

@ -636,11 +636,12 @@
"modalTitle": "План за зареждане", "modalTitle": "План за зареждане",
"none": "няма", "none": "няма",
"planNumber": "План {number}", "planNumber": "План {number}",
"preconditionDescription": "Зареди за {duration} преди тръгване, за да бъде кондиционирана батерията на автомобила.", "precondition": {
"preconditionLong": "Късно зареждане", "description": "Зареди за {duration} преди тръгване, за да бъде кондиционирана батерията на автомобила.",
"preconditionOptionAll": "всичко", "label": "Късно зареждане",
"preconditionOptionNo": "не", "optionAll": "всичко",
"preconditionShort": "Късно", "optionNo": "не"
},
"remove": "Премахване", "remove": "Премахване",
"repeating": "повтарящ се", "repeating": "повтарящ се",
"repeatingPlans": "Повтарящи се планове", "repeatingPlans": "Повтарящи се планове",

View file

@ -814,11 +814,12 @@
"modalTitle": "Plán nabíjení", "modalTitle": "Plán nabíjení",
"none": "Žádný", "none": "Žádný",
"planNumber": "Plán {number}", "planNumber": "Plán {number}",
"preconditionDescription": "Nabíjení bude dokončeno {duration} před časem odjezdu. Vhodné v zimě pro zahřátí baterie či její šetření při vyšším stavu nabití.", "precondition": {
"preconditionLong": "Odložené nabíjení", "description": "Nabíjení bude dokončeno {duration} před časem odjezdu. Vhodné v zimě pro zahřátí baterie či její šetření při vyšším stavu nabití.",
"preconditionOptionAll": "všechen", "label": "Odložené nabíjení",
"preconditionOptionNo": "ne", "optionAll": "všechen",
"preconditionShort": "Odložené nabíjení", "optionNo": "ne"
},
"remove": "Odebrat", "remove": "Odebrat",
"repeating": "opakující se", "repeating": "opakující se",
"repeatingPlans": "Opakující se plány", "repeatingPlans": "Opakující se plány",

View file

@ -889,11 +889,12 @@
"modalTitle": "Lade plan", "modalTitle": "Lade plan",
"none": "ingen", "none": "ingen",
"planNumber": "Plan {number}", "planNumber": "Plan {number}",
"preconditionDescription": "Oplad {duration} før afgang til batterikonditionering.", "precondition": {
"preconditionLong": "Sen opladning", "description": "Oplad {duration} før afgang til batterikonditionering.",
"preconditionOptionAll": "alt", "label": "Sen opladning",
"preconditionOptionNo": "nej", "optionAll": "alt",
"preconditionShort": "Sen", "optionNo": "nej"
},
"remove": "Fjern", "remove": "Fjern",
"repeating": "Gentager", "repeating": "Gentager",
"repeatingPlans": "Gentager plan", "repeatingPlans": "Gentager plan",

View file

@ -877,16 +877,23 @@
"goal": "Ladeziel", "goal": "Ladeziel",
"modalTitle": "Ladeplanung", "modalTitle": "Ladeplanung",
"none": "keiner", "none": "keiner",
"optimization": {
"cheapest": "günstigst",
"continuous": "kontinuierlich",
"label": "Optimierung"
},
"planNumber": "Plan {number}", "planNumber": "Plan {number}",
"preconditionDescription": "Lade {duration} vor Abfahrt zur Batterie-Vorkonditionierung.", "precondition": {
"preconditionLong": "Spätes Laden", "description": "Lade {duration} vor Abfahrt zur Batterie-Vorkonditionierung.",
"preconditionOptionAll": "alles", "label": "Spätes Laden",
"preconditionOptionNo": "nein", "optionAll": "alles",
"preconditionShort": "Spät", "optionNo": "nein"
},
"remove": "Entfernen", "remove": "Entfernen",
"repeating": "wiederholend", "repeating": "wiederholend",
"repeatingPlans": "Wiederholende Pläne", "repeatingPlans": "Wiederholende Pläne",
"selectAll": "Alle wählen", "selectAll": "Alle wählen",
"strategySettings": "Strategie-Einstellungen",
"time": "Zeit", "time": "Zeit",
"title": "Plan", "title": "Plan",
"titleMinSoc": "Min. Ladung", "titleMinSoc": "Min. Ladung",

View file

@ -798,11 +798,12 @@
"modalTitle": "Πρόγραμμα Φόρτισης", "modalTitle": "Πρόγραμμα Φόρτισης",
"none": "κανένα", "none": "κανένα",
"planNumber": "Πρόγραμμα {number}", "planNumber": "Πρόγραμμα {number}",
"preconditionDescription": "Φορτίστε {duration} πριν από την αναχώρηση για προετοιμασία μπαταρίας.", "precondition": {
"preconditionLong": "Καθυστερημένη φόρτιση", "description": "Φορτίστε {duration} πριν από την αναχώρηση για προετοιμασία μπαταρίας.",
"preconditionOptionAll": "Όλα", "label": "Καθυστερημένη φόρτιση",
"preconditionOptionNo": "Όχι", "optionAll": "Όλα",
"preconditionShort": "Αργά", "optionNo": "Όχι"
},
"remove": "Αφαίρεση", "remove": "Αφαίρεση",
"repeating": "επαναλαμβανόμενο", "repeating": "επαναλαμβανόμενο",
"repeatingPlans": "Επαναλαμβανόμενα προγράμματα", "repeatingPlans": "Επαναλαμβανόμενα προγράμματα",

View file

@ -877,16 +877,23 @@
"goal": "Charging goal", "goal": "Charging goal",
"modalTitle": "Charging Plan", "modalTitle": "Charging Plan",
"none": "none", "none": "none",
"optimization": {
"cheapest": "cheapest",
"continuous": "continuous",
"label": "Optimization"
},
"planNumber": "Plan {number}", "planNumber": "Plan {number}",
"preconditionDescription": "Charge {duration} before departure for battery preconditioning.", "precondition": {
"preconditionLong": "Late Charging", "description": "Charge {duration} before departure for battery preconditioning.",
"preconditionOptionAll": "everything", "label": "Late Charging",
"preconditionOptionNo": "no", "optionAll": "everything",
"preconditionShort": "Late", "optionNo": "no"
},
"remove": "Remove", "remove": "Remove",
"repeating": "repeating", "repeating": "repeating",
"repeatingPlans": "Repeating plans", "repeatingPlans": "Repeating plans",
"selectAll": "Select all", "selectAll": "Select all",
"strategySettings": "Strategy settings",
"time": "Time", "time": "Time",
"title": "Plan", "title": "Plan",
"titleMinSoc": "Min charge", "titleMinSoc": "Min charge",

View file

@ -888,11 +888,12 @@
"modalTitle": "Lataussuunnitelma", "modalTitle": "Lataussuunnitelma",
"none": "ei mitään", "none": "ei mitään",
"planNumber": "Suunnitelma {number}", "planNumber": "Suunnitelma {number}",
"preconditionDescription": "Ladataan {duration} ennen lähtöä akunesilämmitystä varten.", "precondition": {
"preconditionLong": "Viimehetken lataus", "description": "Ladataan {duration} ennen lähtöä akunesilämmitystä varten.",
"preconditionOptionAll": "koko latausmäärä", "label": "Viimehetken lataus",
"preconditionOptionNo": "ei", "optionAll": "koko latausmäärä",
"preconditionShort": "Viimehetkellinen", "optionNo": "ei"
},
"remove": "Poista", "remove": "Poista",
"repeating": "toistetaan", "repeating": "toistetaan",
"repeatingPlans": "Toistuvat suunnitelmat", "repeatingPlans": "Toistuvat suunnitelmat",

View file

@ -888,11 +888,12 @@
"modalTitle": "Planification de la charge", "modalTitle": "Planification de la charge",
"none": "aucune", "none": "aucune",
"planNumber": "Planning {number}", "planNumber": "Planning {number}",
"preconditionDescription": "Charge {duration} avant le départ pour pré-conditionner la batterie.", "precondition": {
"preconditionLong": "Chargement tardif", "description": "Charge {duration} avant le départ pour pré-conditionner la batterie.",
"preconditionOptionAll": "tous", "label": "Chargement tardif",
"preconditionOptionNo": "non", "optionAll": "tous",
"preconditionShort": "Tardif", "optionNo": "non"
},
"remove": "Enlever", "remove": "Enlever",
"repeating": "récurrent", "repeating": "récurrent",
"repeatingPlans": "Plannings récurrents", "repeatingPlans": "Plannings récurrents",

View file

@ -818,11 +818,12 @@
"modalTitle": "Plan punjenja", "modalTitle": "Plan punjenja",
"none": "nema, dodaj", "none": "nema, dodaj",
"planNumber": "Plan {number}", "planNumber": "Plan {number}",
"preconditionDescription": "Punjenje {duration} prije polaska kako bi se izvršilo predkondicioniranje baterije.", "precondition": {
"preconditionLong": "Kasnije punjenje", "description": "Punjenje {duration} prije polaska kako bi se izvršilo predkondicioniranje baterije.",
"preconditionOptionAll": "sve", "label": "Kasnije punjenje",
"preconditionOptionNo": "ne", "optionAll": "sve",
"preconditionShort": "Kasno", "optionNo": "ne"
},
"remove": "Ukloni", "remove": "Ukloni",
"repeating": "ponavljanje", "repeating": "ponavljanje",
"repeatingPlans": "Ponavljajući planovi", "repeatingPlans": "Ponavljajući planovi",

View file

@ -786,11 +786,12 @@
"modalTitle": "Töltési Tervezet", "modalTitle": "Töltési Tervezet",
"none": "nincs", "none": "nincs",
"planNumber": "Terv {number}", "planNumber": "Terv {number}",
"preconditionDescription": "Töltse {duration} indulás előtt az akkumulátor előkondícionálásához.", "precondition": {
"preconditionLong": "Késleltetett Töltés", "description": "Töltse {duration} indulás előtt az akkumulátor előkondícionálásához.",
"preconditionOptionAll": "minden", "label": "Késleltetett Töltés",
"preconditionOptionNo": "nem", "optionAll": "minden",
"preconditionShort": "Később", "optionNo": "nem"
},
"remove": "Törlés", "remove": "Törlés",
"repeating": "ismétlődő", "repeating": "ismétlődő",
"repeatingPlans": "Ismétlődő tervek", "repeatingPlans": "Ismétlődő tervek",

View file

@ -616,11 +616,12 @@
"modalTitle": "Piano di ricarica", "modalTitle": "Piano di ricarica",
"none": "nessuno", "none": "nessuno",
"planNumber": "Piano {number}", "planNumber": "Piano {number}",
"preconditionDescription": "Carica {duration} prima della partenza per pre-condizionare la batteria.", "precondition": {
"preconditionLong": "Carica ritardata", "description": "Carica {duration} prima della partenza per pre-condizionare la batteria.",
"preconditionOptionAll": "tutto", "label": "Carica ritardata",
"preconditionOptionNo": "no", "optionAll": "tutto",
"preconditionShort": "Ritardata", "optionNo": "no"
},
"remove": "Rimuovi", "remove": "Rimuovi",
"repeating": "ricorrente", "repeating": "ricorrente",
"repeatingPlans": "Piani ricorrenti", "repeatingPlans": "Piani ricorrenti",

View file

@ -887,11 +887,12 @@
"modalTitle": "Luedplanifikatioun", "modalTitle": "Luedplanifikatioun",
"none": "keng", "none": "keng",
"planNumber": "Planifkatioun {number}", "planNumber": "Planifkatioun {number}",
"preconditionDescription": "Luetzäit {duration} virum Fortfuere fir Batterie-Prekonditionéierung.", "precondition": {
"preconditionLong": "Verspéit Lueden", "description": "Luetzäit {duration} virum Fortfuere fir Batterie-Prekonditionéierung.",
"preconditionOptionAll": "Alles", "label": "Verspéit Lueden",
"preconditionOptionNo": "Nee", "optionAll": "Alles",
"preconditionShort": "Spéit", "optionNo": "Nee"
},
"remove": "Ewechhuelen", "remove": "Ewechhuelen",
"repeating": "widderhuelend", "repeating": "widderhuelend",
"repeatingPlans": "Widderhuelend Planifikatiounen", "repeatingPlans": "Widderhuelend Planifikatiounen",

View file

@ -889,11 +889,12 @@
"modalTitle": "Įkrovimo planas", "modalTitle": "Įkrovimo planas",
"none": "nėra", "none": "nėra",
"planNumber": "Planas {number}", "planNumber": "Planas {number}",
"preconditionDescription": "Įkrovimas {duration} prieš išvykstant, baterijos paruošimui.", "precondition": {
"preconditionLong": "Vėlyvas įkrovimas", "description": "Įkrovimas {duration} prieš išvykstant, baterijos paruošimui.",
"preconditionOptionAll": "viskas", "label": "Vėlyvas įkrovimas",
"preconditionOptionNo": "ne", "optionAll": "viskas",
"preconditionShort": "Vėlai", "optionNo": "ne"
},
"remove": "Pašalinti", "remove": "Pašalinti",
"repeating": "kartojasi", "repeating": "kartojasi",
"repeatingPlans": "Pasikartojantys planai", "repeatingPlans": "Pasikartojantys planai",

View file

@ -888,11 +888,12 @@
"modalTitle": "Laad Plan", "modalTitle": "Laad Plan",
"none": "geen", "none": "geen",
"planNumber": "Schema {number}", "planNumber": "Schema {number}",
"preconditionDescription": "Laad {duration} voor vertrek voor accu voorbereiding.", "precondition": {
"preconditionLong": "Laat Opladen", "description": "Laad {duration} voor vertrek voor accu voorbereiding.",
"preconditionOptionAll": "alles", "label": "Laat Opladen",
"preconditionOptionNo": "nee", "optionAll": "alles",
"preconditionShort": "Laat", "optionNo": "nee"
},
"remove": "Verwijderen", "remove": "Verwijderen",
"repeating": "herhalend", "repeating": "herhalend",
"repeatingPlans": "Herhalende schema's", "repeatingPlans": "Herhalende schema's",

View file

@ -509,11 +509,12 @@
"goal": "Cel ładowania", "goal": "Cel ładowania",
"modalTitle": "Plan ładowania", "modalTitle": "Plan ładowania",
"none": "brak", "none": "brak",
"preconditionDescription": "Naładuj {duration} przed wyjazdem dla wstępnego kondycjonowania akumulatora.", "precondition": {
"preconditionLong": "Późniejsze ładowanie", "description": "Naładuj {duration} przed wyjazdem dla wstępnego kondycjonowania akumulatora.",
"preconditionOptionAll": "wszystko", "label": "Późniejsze ładowanie",
"preconditionOptionNo": "nie", "optionAll": "wszystko",
"preconditionShort": "Później", "optionNo": "nie"
},
"remove": "Wymaż", "remove": "Wymaż",
"repeating": "powtórzenie", "repeating": "powtórzenie",
"repeatingPlans": "Powtarzające się plany", "repeatingPlans": "Powtarzające się plany",

View file

@ -887,11 +887,12 @@
"modalTitle": "Plano de carga", "modalTitle": "Plano de carga",
"none": "nenhum", "none": "nenhum",
"planNumber": "Plano {number}", "planNumber": "Plano {number}",
"preconditionDescription": "Carregar {duration} antes da partida para pré-condicionamento da bateria.", "precondition": {
"preconditionLong": "Carga tardia", "description": "Carregar {duration} antes da partida para pré-condicionamento da bateria.",
"preconditionOptionAll": "tudo", "label": "Carga tardia",
"preconditionOptionNo": "não", "optionAll": "tudo",
"preconditionShort": "Tarde", "optionNo": "não"
},
"remove": "Apagar", "remove": "Apagar",
"repeating": "recorrente", "repeating": "recorrente",
"repeatingPlans": "Repetição de planos", "repeatingPlans": "Repetição de planos",

View file

@ -889,11 +889,12 @@
"modalTitle": "Laddplan", "modalTitle": "Laddplan",
"none": "ingen", "none": "ingen",
"planNumber": "Plan {number}", "planNumber": "Plan {number}",
"preconditionDescription": "Ladda {duration} före avfärd för batteriuppvärmning.", "precondition": {
"preconditionLong": "Sen laddning", "description": "Ladda {duration} före avfärd för batteriuppvärmning.",
"preconditionOptionAll": "allt", "label": "Sen laddning",
"preconditionOptionNo": "nej", "optionAll": "allt",
"preconditionShort": "Sen", "optionNo": "nej"
},
"remove": "Ta bort", "remove": "Ta bort",
"repeating": "återkommande", "repeating": "återkommande",
"repeatingPlans": "Återkommande planer", "repeatingPlans": "Återkommande planer",

View file

@ -683,11 +683,12 @@
"modalTitle": "மின்சேர்வி திட்டம்", "modalTitle": "மின்சேர்வி திட்டம்",
"none": "எதுவுமில்லை", "none": "எதுவுமில்லை",
"planNumber": "திட்டம் {number}", "planNumber": "திட்டம் {number}",
"preconditionDescription": "பேட்டரி முன்நிபந்தனைக்கு புறப்படுவதற்கு முன் {duration} சார்ஜ் செய்யவும்.", "precondition": {
"preconditionLong": "தாமதமாக சார்சிங்", "description": "பேட்டரி முன்நிபந்தனைக்கு புறப்படுவதற்கு முன் {duration} சார்ஜ் செய்யவும்.",
"preconditionOptionAll": "எல்லாம்", "label": "தாமதமாக சார்சிங்",
"preconditionOptionNo": "இல்லை", "optionAll": "எல்லாம்",
"preconditionShort": "தாமதமாக", "optionNo": "இல்லை"
},
"remove": "அகற்று", "remove": "அகற்று",
"repeating": "மறுநிகழ்தல்", "repeating": "மறுநிகழ்தல்",
"repeatingPlans": "மறுநிகழ்வு திட்டங்கள்", "repeatingPlans": "மறுநிகழ்வு திட்டங்கள்",

View file

@ -887,11 +887,12 @@
"modalTitle": "Doldurma Planı", "modalTitle": "Doldurma Planı",
"none": "yok", "none": "yok",
"planNumber": "Plan {number}", "planNumber": "Plan {number}",
"preconditionDescription": "Batarya ön ısıtması için kalkıştan önce {duration} doldur.", "precondition": {
"preconditionLong": "Geç doldurma", "description": "Batarya ön ısıtması için kalkıştan önce {duration} doldur.",
"preconditionOptionAll": "hepsi", "label": "Geç doldurma",
"preconditionOptionNo": "hayır", "optionAll": "hepsi",
"preconditionShort": "Geç", "optionNo": "hayır"
},
"remove": "Kaldır", "remove": "Kaldır",
"repeating": "“tekrarlanan”", "repeating": "“tekrarlanan”",
"repeatingPlans": "“Tekrarlanan planlar”", "repeatingPlans": "“Tekrarlanan planlar”",

View file

@ -889,11 +889,12 @@
"modalTitle": "Тарифний план", "modalTitle": "Тарифний план",
"none": "немає", "none": "немає",
"planNumber": "План {number}", "planNumber": "План {number}",
"preconditionDescription": "Зарядіть {duration} перед відправленням для попередньої підготовки акумулятора.", "precondition": {
"preconditionLong": "Пізня зарядка", "description": "Зарядіть {duration} перед відправленням для попередньої підготовки акумулятора.",
"preconditionOptionAll": "все", "label": "Пізня зарядка",
"preconditionOptionNo": "ні", "optionAll": "все",
"preconditionShort": "Пізно", "optionNo": "ні"
},
"remove": "Yсувати", "remove": "Yсувати",
"repeating": "повторення", "repeating": "повторення",
"repeatingPlans": "Повторювані плани", "repeatingPlans": "Повторювані плани",

View file

@ -610,11 +610,12 @@
"modalTitle": "充电计划", "modalTitle": "充电计划",
"none": "无", "none": "无",
"planNumber": "计划 {number}", "planNumber": "计划 {number}",
"preconditionDescription": "出发前充电 {duration} 以进行电池预处理。", "precondition": {
"preconditionLong": "延迟充电", "description": "出发前充电 {duration} 以进行电池预处理。",
"preconditionOptionAll": "全部", "label": "延迟充电",
"preconditionOptionNo": "否", "optionAll": "全部",
"preconditionShort": "延迟", "optionNo": "否"
},
"remove": "移除", "remove": "移除",
"repeating": "重复", "repeating": "重复",
"repeatingPlans": "重复计划", "repeatingPlans": "重复计划",

View file

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

View file

@ -9,7 +9,6 @@ import (
"slices" "slices"
"strconv" "strconv"
"strings" "strings"
"time"
) )
// pass converts a simple api without return value to api with nil error return value // pass converts a simple api without return value to api with nil error return value
@ -29,21 +28,6 @@ func parseFloat(payload string) (float64, error) {
return f, err 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 // jsonDecoder returns a json decoder with disallowed unknown fields
func jsonDecoder(r io.Reader) *json.Decoder { func jsonDecoder(r io.Reader) *json.Decoder {
dec := json.NewDecoder(r) dec := json.NewDecoder(r)

View file

@ -173,6 +173,7 @@ func (s *HTTPd) RegisterSiteHandlers(site site.API, valueChan chan<- util.Param)
"plan": {"POST", "/vehicles/{name:[a-zA-Z0-9_.:-]+}/plan/soc/{value:[0-9]+}/{time:[0-9TZ:.+-]+}", planSocHandler(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)}, "plan2": {"DELETE", "/vehicles/{name:[a-zA-Z0-9_.:-]+}/plan/soc", planSocRemoveHandler(site)},
"repeatingPlans": {"POST", "/vehicles/{name:[a-zA-Z0-9_.:-]+}/plan/repeating", addRepeatingPlansHandler(site)}, "repeatingPlans": {"POST", "/vehicles/{name:[a-zA-Z0-9_.:-]+}/plan/repeating", addRepeatingPlansHandler(site)},
"planStrategy": {"POST", "/vehicles/{name:[a-zA-Z0-9_.:-]+}/plan/strategy", updatePlanStrategyHandler(site)},
// config ui // config ui
// "mode": {"POST", "/mode/{value:[a-z]+}", chargeModeHandler(v)}, // "mode": {"POST", "/mode/{value:[a-z]+}", chargeModeHandler(v)},
@ -199,9 +200,9 @@ func (s *HTTPd) RegisterSiteHandlers(site site.API, valueChan chan<- util.Param)
"phases": {"POST", "/phases/{value:[0-9]+}", intHandler(lp.SetPhasesConfigured, lp.GetPhasesConfigured)}, "phases": {"POST", "/phases/{value:[0-9]+}", intHandler(lp.SetPhasesConfigured, lp.GetPhasesConfigured)},
"plan": {"GET", "/plan", planHandler(lp)}, "plan": {"GET", "/plan", planHandler(lp)},
"staticPlanPreview": {"GET", "/plan/static/preview/{type:(?:soc|energy)}/{value:[0-9.]+}/{time:[0-9TZ:.+-]+}", staticPlanPreviewHandler(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)}, "planenergy": {"POST", "/plan/energy/{value:[0-9.]+}/{time:[0-9TZ:.+-]+}", planEnergyHandler(lp)},
"planenergy2": {"DELETE", "/plan/energy", planRemoveHandler(lp)}, "planenergy2": {"DELETE", "/plan/energy", planRemoveHandler(lp)},
"planStrategy": {"POST", "/plan/strategy", planStrategyHandler(lp)},
"vehicle": {"POST", "/vehicle/{name:[a-zA-Z0-9_.:-]+}", vehicleSelectHandler(site, lp)}, "vehicle": {"POST", "/vehicle/{name:[a-zA-Z0-9_.:-]+}", vehicleSelectHandler(site, lp)},
"vehicle2": {"DELETE", "/vehicle", vehicleRemoveHandler(lp)}, "vehicle2": {"DELETE", "/vehicle", vehicleRemoveHandler(lp)},
"vehicleDetect": {"PATCH", "/vehicle", vehicleDetectHandler(lp)}, "vehicleDetect": {"PATCH", "/vehicle", vehicleDetectHandler(lp)},

View file

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

View file

@ -5,31 +5,31 @@ import (
"fmt" "fmt"
"net/http" "net/http"
"strconv" "strconv"
"strings"
"time" "time"
"github.com/evcc-io/evcc/api" "github.com/evcc-io/evcc/api"
"github.com/evcc-io/evcc/core/loadpoint" "github.com/evcc-io/evcc/core/loadpoint"
"github.com/evcc-io/evcc/core/site" "github.com/evcc-io/evcc/core/site"
"github.com/evcc-io/evcc/util"
"github.com/gorilla/mux" "github.com/gorilla/mux"
) )
type PlanResponse struct { type PlanResponse struct {
PlanId int `json:"planId"` PlanId int `json:"planId"`
PlanTime time.Time `json:"planTime"` PlanTime time.Time `json:"planTime"`
Duration int64 `json:"duration"` Duration int64 `json:"duration"`
Precondition int64 `json:"precondition"` Plan api.Rates `json:"plan"`
Plan api.Rates `json:"plan"` Power float64 `json:"power"`
Power float64 `json:"power"`
} }
type PlanPreviewResponse struct { type PlanPreviewResponse struct {
PlanTime time.Time `json:"planTime"` PlanTime time.Time `json:"planTime"`
Duration int64 `json:"duration"` Duration int64 `json:"duration"`
Precondition int64 `json:"precondition"` Plan api.Rates `json:"plan"`
Plan api.Rates `json:"plan"` Power float64 `json:"power"`
Power float64 `json:"power"`
// TODO remove
Continuous bool `json:"continuous"`
Precondition int64 `json:"precondition"`
} }
// planHandler returns the current plan // planHandler returns the current plan
@ -40,17 +40,16 @@ func planHandler(lp loadpoint.API) http.HandlerFunc {
id := lp.EffectivePlanId() id := lp.EffectivePlanId()
goal, _ := lp.GetPlanGoal() goal, _ := lp.GetPlanGoal()
precondition := lp.GetPlanPreCondDuration()
requiredDuration := lp.GetPlanRequiredDuration(goal, maxPower) requiredDuration := lp.GetPlanRequiredDuration(goal, maxPower)
plan := lp.GetPlan(planTime, requiredDuration, precondition) strategy := lp.EffectivePlanStrategy()
plan := lp.GetPlan(planTime, requiredDuration, strategy.Precondition, strategy.Continuous)
res := PlanResponse{ res := PlanResponse{
PlanId: id, PlanId: id,
PlanTime: planTime, PlanTime: planTime,
Duration: int64(requiredDuration.Seconds()), Duration: int64(requiredDuration.Seconds()),
Precondition: int64(precondition.Seconds()), Plan: plan,
Plan: plan, Power: maxPower,
Power: maxPower,
} }
jsonWrite(w, res) jsonWrite(w, res)
@ -61,7 +60,6 @@ func planHandler(lp loadpoint.API) http.HandlerFunc {
func staticPlanPreviewHandler(lp loadpoint.API) http.HandlerFunc { func staticPlanPreviewHandler(lp loadpoint.API) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r) vars := mux.Vars(r)
query := r.URL.Query()
planTime, err := time.ParseInLocation(time.RFC3339, vars["time"], nil) planTime, err := time.ParseInLocation(time.RFC3339, vars["time"], nil)
if err != nil { if err != nil {
@ -75,12 +73,6 @@ func staticPlanPreviewHandler(lp loadpoint.API) http.HandlerFunc {
return return
} }
precondition, err := parseDuration(query.Get("precondition"))
if err != nil {
jsonError(w, http.StatusBadRequest, err)
return
}
switch typ := vars["type"]; typ { switch typ := vars["type"]; typ {
case "soc": case "soc":
if !lp.SocBasedPlanning() { if !lp.SocBasedPlanning() {
@ -99,66 +91,19 @@ func staticPlanPreviewHandler(lp loadpoint.API) http.HandlerFunc {
maxPower := lp.EffectiveMaxPower() maxPower := lp.EffectiveMaxPower()
requiredDuration := lp.GetPlanRequiredDuration(goal, maxPower) requiredDuration := lp.GetPlanRequiredDuration(goal, maxPower)
plan := lp.GetPlan(planTime, requiredDuration, precondition) strategy := lp.EffectivePlanStrategy()
plan := lp.GetPlan(planTime, requiredDuration, strategy.Precondition, strategy.Continuous)
res := PlanPreviewResponse{ res := PlanPreviewResponse{
PlanTime: planTime, PlanTime: planTime,
Duration: int64(requiredDuration.Seconds()), Duration: int64(requiredDuration.Seconds()),
Precondition: int64(precondition.Seconds()), Plan: plan,
Plan: plan, Power: maxPower,
Power: maxPower,
}
jsonWrite(w, res) // TODO remove
} Continuous: strategy.Continuous,
} Precondition: int64(strategy.Precondition.Seconds()),
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"]
var weekdays []int
for weekdayStr := range strings.SplitSeq(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
}
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, precondition)
res := PlanPreviewResponse{
PlanTime: planTime,
Duration: int64(requiredDuration.Seconds()),
Precondition: int64(precondition.Seconds()),
Plan: plan,
Power: maxPower,
} }
jsonWrite(w, res) jsonWrite(w, res)
@ -169,7 +114,6 @@ func repeatingPlanPreviewHandler(lp loadpoint.API) http.HandlerFunc {
func planEnergyHandler(lp loadpoint.API) http.HandlerFunc { func planEnergyHandler(lp loadpoint.API) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r) vars := mux.Vars(r)
query := r.URL.Query()
ts, err := time.ParseInLocation(time.RFC3339, vars["time"], nil) ts, err := time.ParseInLocation(time.RFC3339, vars["time"], nil)
if err != nil { if err != nil {
@ -183,27 +127,19 @@ func planEnergyHandler(lp loadpoint.API) http.HandlerFunc {
return return
} }
precondition, err := parseDuration(query.Get("precondition")) if err := lp.SetPlanEnergy(ts, val); err != nil {
if err != nil {
jsonError(w, http.StatusBadRequest, err) jsonError(w, http.StatusBadRequest, err)
return return
} }
if err := lp.SetPlanEnergy(ts, precondition, val); err != nil { ts, energy := lp.GetPlanEnergy()
jsonError(w, http.StatusBadRequest, err)
return
}
ts, precondition, energy := lp.GetPlanEnergy()
res := struct { res := struct {
Energy float64 `json:"energy"` Energy float64 `json:"energy"`
Precondition int64 `json:"precondition"` Time time.Time `json:"time"`
Time time.Time `json:"time"`
}{ }{
Energy: energy, Energy: energy,
Precondition: int64(precondition.Seconds()), Time: ts,
Time: ts,
} }
jsonWrite(w, res) jsonWrite(w, res)
@ -213,13 +149,12 @@ func planEnergyHandler(lp loadpoint.API) http.HandlerFunc {
// planRemoveHandler removes plan time // planRemoveHandler removes plan time
func planRemoveHandler(lp loadpoint.API) http.HandlerFunc { func planRemoveHandler(lp loadpoint.API) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
if err := lp.SetPlanEnergy(time.Time{}, 0, 0); err != nil { if err := lp.SetPlanEnergy(time.Time{}, 0); err != nil {
jsonError(w, http.StatusBadRequest, err) jsonError(w, http.StatusBadRequest, err)
return return
} }
res := struct{}{} jsonWrite(w, struct{}{})
jsonWrite(w, res)
} }
} }
@ -251,8 +186,7 @@ func vehicleSelectHandler(site site.API, lp loadpoint.API) http.HandlerFunc {
func vehicleRemoveHandler(lp loadpoint.API) http.HandlerFunc { func vehicleRemoveHandler(lp loadpoint.API) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
lp.SetVehicle(nil) lp.SetVehicle(nil)
res := struct{}{} jsonWrite(w, struct{}{})
jsonWrite(w, res)
} }
} }
@ -260,7 +194,20 @@ func vehicleRemoveHandler(lp loadpoint.API) http.HandlerFunc {
func vehicleDetectHandler(lp loadpoint.API) http.HandlerFunc { func vehicleDetectHandler(lp loadpoint.API) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
lp.StartVehicleDetection() lp.StartVehicleDetection()
res := struct{}{} jsonWrite(w, struct{}{})
}
}
// planStrategyHandler updates plan strategy for loadpoint
func planStrategyHandler(lp loadpoint.API) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if err := planStrategyHandlerSetter(r, lp.SetPlanStrategy); err != nil {
jsonError(w, http.StatusBadRequest, err)
return
}
res := planStrategyPayloadFromApi(lp.GetPlanStrategy())
jsonWrite(w, res) jsonWrite(w, res)
} }
} }

View file

@ -73,7 +73,6 @@ func limitSocHandler(site site.API) http.HandlerFunc {
func planSocHandler(site site.API) http.HandlerFunc { func planSocHandler(site site.API) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r) vars := mux.Vars(r)
query := r.URL.Query()
v, err := site.Vehicles().ByName(vars["name"]) v, err := site.Vehicles().ByName(vars["name"])
if err != nil { if err != nil {
@ -93,28 +92,52 @@ func planSocHandler(site site.API) http.HandlerFunc {
return return
} }
precondition, err := parseDuration(query.Get("precondition")) if err := v.SetPlanSoc(ts, soc); err != nil {
jsonError(w, http.StatusBadRequest, err)
return
}
ts, soc = v.GetPlanSoc()
res := struct {
Soc int `json:"soc"`
Time time.Time `json:"time"`
}{
Soc: soc,
Time: ts,
}
jsonWrite(w, res)
}
}
func planStrategyHandlerSetter(r *http.Request, set func(api.PlanStrategy) error) error {
var planStrategy planStrategyPayload
if err := json.NewDecoder(r.Body).Decode(&planStrategy); err != nil {
return err
}
return set(api.PlanStrategy{
Continuous: planStrategy.Continuous,
Precondition: time.Duration(planStrategy.Precondition) * time.Second,
})
}
// updatePlanStrategyHandler updates plan strategy
func updatePlanStrategyHandler(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 { if err != nil {
jsonError(w, http.StatusBadRequest, err) jsonError(w, http.StatusBadRequest, err)
return return
} }
if err := v.SetPlanSoc(ts, precondition, soc); err != nil { if err := planStrategyHandlerSetter(r, v.SetPlanStrategy); err != nil {
jsonError(w, http.StatusBadRequest, err) jsonError(w, http.StatusBadRequest, err)
return return
} }
ts, precondition, soc = v.GetPlanSoc() res := planStrategyPayloadFromApi(v.GetPlanStrategy())
res := struct {
Soc int `json:"soc"`
Precondition int64 `json:"precondition"`
Time time.Time `json:"time"`
}{
Soc: soc,
Precondition: int64(precondition.Seconds()),
Time: ts,
}
jsonWrite(w, res) jsonWrite(w, res)
} }
@ -157,12 +180,11 @@ func planSocRemoveHandler(site site.API) http.HandlerFunc {
return return
} }
if err := v.SetPlanSoc(time.Time{}, 0, 0); err != nil { if err := v.SetPlanSoc(time.Time{}, 0); err != nil {
jsonError(w, http.StatusBadRequest, err) jsonError(w, http.StatusBadRequest, err)
return return
} }
res := struct{}{} jsonWrite(w, struct{}{})
jsonWrite(w, res)
} }
} }

View file

@ -594,6 +594,21 @@
}, },
"type": "object" "type": "object"
}, },
"PlanStrategy": {
"description": "Charging plan strategy configuration",
"properties": {
"continuous": {
"description": "Force continuous planning",
"type": "boolean"
},
"precondition": {
"description": "Precondition duration in seconds",
"minimum": 0,
"type": "integer"
}
},
"type": "object"
},
"Power": { "Power": {
"description": "Power in W", "description": "Power in W",
"example": 2500, "example": 2500,
@ -638,9 +653,6 @@
"description": "Set plan active.", "description": "Set plan active.",
"type": "boolean" "type": "boolean"
}, },
"precondition": {
"$ref": "#/components/schemas/Precondition"
},
"soc": { "soc": {
"$ref": "#/components/schemas/Soc" "$ref": "#/components/schemas/Soc"
}, },
@ -675,9 +687,6 @@
"energy": { "energy": {
"$ref": "#/components/schemas/Energy" "$ref": "#/components/schemas/Energy"
}, },
"precondition": {
"$ref": "#/components/schemas/Precondition"
},
"time": { "time": {
"$ref": "#/components/schemas/Timestamp" "$ref": "#/components/schemas/Timestamp"
} }
@ -689,9 +698,6 @@
"url": "https://docs.evcc.io/en/docs/features/plans#create-charging-plan" "url": "https://docs.evcc.io/en/docs/features/plans#create-charging-plan"
}, },
"properties": { "properties": {
"precondition": {
"$ref": "#/components/schemas/Precondition"
},
"soc": { "soc": {
"$ref": "#/components/schemas/Soc" "$ref": "#/components/schemas/Soc"
}, },
@ -1423,9 +1429,6 @@
"properties": { "properties": {
"planId": { "planId": {
"$ref": "#/components/schemas/Id" "$ref": "#/components/schemas/Id"
},
"precondition": {
"$ref": "#/components/schemas/Precondition"
} }
}, },
"type": "object" "type": "object"
@ -1606,6 +1609,51 @@
] ]
} }
}, },
"/loadpoints/{id}/plan/strategy": {
"post": {
"description": "Updates the charging plan strategy for the loadpoint.",
"externalDocs": {
"url": "https://docs.evcc.io/en/docs/features/plans"
},
"operationId": "setLoadpointPlanStrategy",
"parameters": [
{
"$ref": "#/components/parameters/id"
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/PlanStrategy"
}
}
},
"required": true
},
"responses": {
"200": {
"content": {
"application/json": {
"schema": {
"properties": {
"result": {
"$ref": "#/components/schemas/PlanStrategy"
}
},
"type": "object"
}
}
},
"description": "Success"
}
},
"summary": "Set plan strategy",
"tags": [
"loadpoints"
]
}
},
"/loadpoints/{id}/priority/{priority}": { "/loadpoints/{id}/priority/{priority}": {
"post": { "post": {
"description": "Set loadpoint priority.", "description": "Set loadpoint priority.",
@ -2502,13 +2550,6 @@
}, },
{ {
"$ref": "#/components/parameters/timestamp" "$ref": "#/components/parameters/timestamp"
},
{
"in": "query",
"name": "precondition",
"schema": {
"$ref": "#/components/schemas/Precondition"
}
} }
], ],
"responses": { "responses": {
@ -2518,7 +2559,15 @@
"schema": { "schema": {
"properties": { "properties": {
"result": { "result": {
"$ref": "#/components/schemas/StaticSocPlan" "properties": {
"soc": {
"$ref": "#/components/schemas/Soc"
},
"time": {
"$ref": "#/components/schemas/Timestamp"
}
},
"type": "object"
} }
}, },
"type": "object" "type": "object"
@ -2533,6 +2582,51 @@
"vehicles" "vehicles"
] ]
} }
},
"/vehicles/{name}/plan/strategy": {
"post": {
"description": "Updates the charging plan strategy for the vehicle.",
"externalDocs": {
"url": "https://docs.evcc.io/en/docs/features/plans"
},
"operationId": "setVehiclePlanStrategy",
"parameters": [
{
"$ref": "#/components/parameters/vehicleName"
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/PlanStrategy"
}
}
},
"required": true
},
"responses": {
"200": {
"content": {
"application/json": {
"schema": {
"properties": {
"result": {
"$ref": "#/components/schemas/PlanStrategy"
}
},
"type": "object"
}
}
},
"description": "Success"
}
},
"summary": "Set plan strategy",
"tags": [
"vehicles"
]
}
} }
}, },
"tags": [ "tags": [

View file

@ -730,6 +730,28 @@ call setLoadpointPhases {
} }
``` ```
## setLoadpointPlanStrategy
Updates the charging plan strategy for the loadpoint.
**Tags:** loadpoints
**Arguments:**
| Name | Type | Description |
|------|------|-------------|
| id | integer | Loadpoint index starting at 1 |
| requestBody | object | The JSON request body. |
**Example call:**
```json
call setLoadpointPlanStrategy {
"id": 123,
"requestBody": "..."
}
```
## setLoadpointPriority ## setLoadpointPriority
Set loadpoint priority. Set loadpoint priority.
@ -1054,6 +1076,28 @@ call setVehicleMinSoc {
} }
``` ```
## setVehiclePlanStrategy
Updates the charging plan strategy for the vehicle.
**Tags:** vehicles
**Arguments:**
| Name | Type | Description |
|------|------|-------------|
| name | string | Vehicle name |
| requestBody | object | The JSON request body. |
**Example call:**
```json
call setVehiclePlanStrategy {
"name": "example",
"requestBody": "..."
}
```
## setVehicleSocLimit ## setVehicleSocLimit
Charging will stop when this SoC is reached. Charging will stop when this SoC is reached.
@ -1087,7 +1131,6 @@ Create charging plan with fixed time and SoC target.
| Name | Type | Description | | Name | Type | Description |
|------|------|-------------| |------|------|-------------|
| name | string | Vehicle name | | name | string | Vehicle name |
| precondition | integer | Late charging duration in seconds. |
| soc | number | SOC in % | | soc | number | SOC in % |
| timestamp | string | Timestamp in RFC3339 format | | timestamp | string | Timestamp in RFC3339 format |
@ -1096,7 +1139,6 @@ Create charging plan with fixed time and SoC target.
```json ```json
call setVehicleSocPlan { call setVehicleSocPlan {
"name": "example", "name": "example",
"precondition": 123,
"soc": 123.45, "soc": 123.45,
"timestamp": "example" "timestamp": "example"
} }

View file

@ -1,7 +1,6 @@
package server package server
import ( import (
"encoding/json"
"fmt" "fmt"
"reflect" "reflect"
"strconv" "strconv"
@ -252,18 +251,8 @@ func (m *MQTT) listenLoadpointSetters(topic string, site site.API, lp loadpoint.
{"smartCostLimit", floatPtrSetter(pass(lp.SetSmartCostLimit))}, {"smartCostLimit", floatPtrSetter(pass(lp.SetSmartCostLimit))},
{"smartFeedInPriorityLimit", floatPtrSetter(pass(lp.SetSmartFeedInPriorityLimit))}, {"smartFeedInPriorityLimit", floatPtrSetter(pass(lp.SetSmartFeedInPriorityLimit))},
{"batteryBoost", boolSetter(lp.SetBatteryBoost)}, {"batteryBoost", boolSetter(lp.SetBatteryBoost)},
{"planEnergy", func(payload string) error { {"planStrategy", planStrategySetter(lp.SetPlanStrategy)},
var plan struct { {"planEnergy", planGoalSetter(lp.SetPlanEnergy)},
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, time.Duration(plan.Precondition)*time.Second, plan.Value)
}
return err
}},
{"vehicle", func(payload string) error { {"vehicle", func(payload string) error {
// https://github.com/evcc-io/evcc/issues/11184 empty payload is swallowed by listener // https://github.com/evcc-io/evcc/issues/11184 empty payload is swallowed by listener
if isEmpty(payload) { if isEmpty(payload) {
@ -289,18 +278,8 @@ func (m *MQTT) listenVehicleSetters(topic string, v vehicle.API) error {
for _, s := range []setter{ for _, s := range []setter{
{"limitSoc", intSetter(pass(v.SetLimitSoc))}, {"limitSoc", intSetter(pass(v.SetLimitSoc))},
{"minSoc", intSetter(pass(v.SetMinSoc))}, {"minSoc", intSetter(pass(v.SetMinSoc))},
{"planSoc", func(payload string) error { {"planStrategy", planStrategySetter(v.SetPlanStrategy)},
var plan struct { {"planSoc", planGoalSetter(v.SetPlanSoc)},
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, time.Duration(plan.Precondition)*time.Second, plan.Value)
}
return err
}},
} { } {
if err := m.Handler.ListenSetter(topic+"/"+s.topic, s.fun); err != nil { if err := m.Handler.ListenSetter(topic+"/"+s.topic, s.fun); err != nil {
return err return err

View file

@ -1,10 +1,12 @@
package server package server
import ( import (
"encoding/json"
"slices" "slices"
"strconv" "strconv"
"time" "time"
"github.com/evcc-io/evcc/api"
"github.com/evcc-io/evcc/util" "github.com/evcc-io/evcc/util"
"github.com/spf13/cast" "github.com/spf13/cast"
) )
@ -63,3 +65,28 @@ func boolSetter(set func(bool) error) func(string) error {
func durationSetter(set func(time.Duration) error) func(string) error { func durationSetter(set func(time.Duration) error) func(string) error {
return setterFunc(util.ParseDuration, set) return setterFunc(util.ParseDuration, set)
} }
func planStrategySetter(set func(api.PlanStrategy) error) func(string) error {
return func(payload string) error {
var planStrategy planStrategyPayload
if err := json.Unmarshal([]byte(payload), &planStrategy); err != nil {
return err
}
return set(api.PlanStrategy{
Continuous: planStrategy.Continuous,
Precondition: time.Duration(planStrategy.Precondition) * time.Second,
})
}
}
func planGoalSetter[T any](set func(time.Time, T) error) func(string) error {
return func(payload string) error {
var plan planGoal[T]
if err := json.Unmarshal([]byte(payload), &plan); err != nil {
return err
}
return set(plan.Time, plan.Value)
}
}

View file

@ -398,8 +398,6 @@ paths:
properties: properties:
planId: planId:
$ref: "#/components/schemas/Id" $ref: "#/components/schemas/Id"
precondition:
$ref: "#/components/schemas/Precondition"
- $ref: "#/components/schemas/PlanRates" - $ref: "#/components/schemas/PlanRates"
/loadpoints/{id}/plan/energy: /loadpoints/{id}/plan/energy:
delete: delete:
@ -488,6 +486,33 @@ paths:
responses: responses:
"200": "200":
$ref: "#/components/responses/PlanRatesResult" $ref: "#/components/responses/PlanRatesResult"
/loadpoints/{id}/plan/strategy:
post:
operationId: setLoadpointPlanStrategy
summary: Set plan strategy
description: "Updates the charging plan strategy for the loadpoint."
externalDocs:
url: https://docs.evcc.io/en/docs/features/plans
tags:
- loadpoints
parameters:
- $ref: "#/components/parameters/id"
requestBody:
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/PlanStrategy"
responses:
200:
description: Success
content:
application/json:
schema:
type: object
properties:
result:
$ref: "#/components/schemas/PlanStrategy"
/loadpoints/{id}/priority/{priority}: /loadpoints/{id}/priority/{priority}:
post: post:
operationId: setLoadpointPriority operationId: setLoadpointPriority
@ -1080,10 +1105,6 @@ paths:
- $ref: "#/components/parameters/vehicleName" - $ref: "#/components/parameters/vehicleName"
- $ref: "#/components/parameters/soc" - $ref: "#/components/parameters/soc"
- $ref: "#/components/parameters/timestamp" - $ref: "#/components/parameters/timestamp"
- in: query
name: precondition
schema:
$ref: "#/components/schemas/Precondition"
responses: responses:
"200": "200":
description: Success description: Success
@ -1093,7 +1114,39 @@ paths:
type: object type: object
properties: properties:
result: result:
$ref: "#/components/schemas/StaticSocPlan" type: object
properties:
soc:
$ref: "#/components/schemas/Soc"
time:
$ref: "#/components/schemas/Timestamp"
/vehicles/{name}/plan/strategy:
post:
operationId: setVehiclePlanStrategy
summary: Set plan strategy
description: "Updates the charging plan strategy for the vehicle."
externalDocs:
url: https://docs.evcc.io/en/docs/features/plans
tags:
- vehicles
parameters:
- $ref: "#/components/parameters/vehicleName"
requestBody:
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/PlanStrategy"
responses:
200:
description: Success
content:
application/json:
schema:
type: object
properties:
result:
$ref: "#/components/schemas/PlanStrategy"
components: components:
schemas: schemas:
BatteryMode: BatteryMode:
@ -1304,6 +1357,17 @@ components:
type: array type: array
items: items:
$ref: "#/components/schemas/Rate" $ref: "#/components/schemas/Rate"
PlanStrategy:
description: Charging plan strategy configuration
type: object
properties:
continuous:
description: "Force continuous planning"
type: boolean
precondition:
description: "Precondition duration in seconds"
type: integer
minimum: 0
RepeatingPlan: RepeatingPlan:
externalDocs: externalDocs:
url: https://docs.evcc.io/en/docs/features/plans#repeating-plans url: https://docs.evcc.io/en/docs/features/plans#repeating-plans
@ -1312,8 +1376,6 @@ components:
active: active:
description: "Set plan active." description: "Set plan active."
type: boolean type: boolean
precondition:
$ref: "#/components/schemas/Precondition"
soc: soc:
$ref: "#/components/schemas/Soc" $ref: "#/components/schemas/Soc"
time: time:
@ -1338,8 +1400,6 @@ components:
properties: properties:
energy: energy:
$ref: "#/components/schemas/Energy" $ref: "#/components/schemas/Energy"
precondition:
$ref: "#/components/schemas/Precondition"
time: time:
$ref: "#/components/schemas/Timestamp" $ref: "#/components/schemas/Timestamp"
StaticSocPlan: StaticSocPlan:
@ -1347,8 +1407,6 @@ components:
url: https://docs.evcc.io/en/docs/features/plans#create-charging-plan url: https://docs.evcc.io/en/docs/features/plans#create-charging-plan
type: object type: object
properties: properties:
precondition:
$ref: "#/components/schemas/Precondition"
soc: soc:
$ref: "#/components/schemas/Soc" $ref: "#/components/schemas/Soc"
time: time:

24
server/types.go Normal file
View file

@ -0,0 +1,24 @@
package server
import (
"time"
"github.com/evcc-io/evcc/api"
)
type planStrategyPayload struct {
Continuous bool `json:"continuous"`
Precondition int64 `json:"precondition"`
}
func planStrategyPayloadFromApi(ps api.PlanStrategy) planStrategyPayload {
return planStrategyPayload{
Continuous: ps.Continuous,
Precondition: int64(ps.Precondition.Seconds()),
}
}
type planGoal[T any] struct {
Time time.Time `json:"time"`
Value T `json:"value"`
}

View file

@ -16,7 +16,9 @@ meters:
loadpoints: loadpoints:
- title: Loadpoint - title: Loadpoint
charger: charger charger: charger
mode: pv
- title: Loadpoint with SoC - title: Loadpoint with SoC
mode: pv
charger: chargerSoc charger: chargerSoc
chargers: chargers:

View file

@ -79,24 +79,22 @@ test.describe("basic functionality", async () => {
await page.getByTestId("static-plan-day").selectOption({ index: 1 }); await page.getByTestId("static-plan-day").selectOption({ index: 1 });
await page.getByTestId("static-plan-time").fill("09:30"); await page.getByTestId("static-plan-time").fill("09:30");
await page.getByTestId("static-plan-soc").selectOption("80%"); await page.getByTestId("static-plan-soc").selectOption("80%");
await page.getByTestId("static-plan-precondition-lg-toggle").click(); await page.getByRole("button", { name: "Strategy settings" }).click();
await page await expect(page.getByLabel("Late Charging")).toBeVisible();
.getByTestId("static-plan-precondition-lg-select") await page.getByLabel("Late Charging").selectOption("1 hour");
.getByRole("combobox")
.selectOption("1 hour");
await page.getByTestId("static-plan-active").click(); await page.getByTestId("static-plan-active").click();
await page.getByRole("button", { name: "Close" }).click(); await page.getByRole("button", { name: "Close" }).click();
await expect(lp1.getByTestId("plan-marker")).toBeVisible(); await expect(lp1.getByTestId("plan-marker")).toBeVisible();
await expect(lp1.getByTestId("charging-plan").getByRole("button")).toHaveText( await expect(lp1.getByTestId("charging-plan").getByRole("button")).toHaveText(
"tomorrow 09:3080%" ["tomorrow 09:30", "80%"].join("")
); );
await expect(lp1.getByTestId("vehicle-status-charger")).toHaveText("Connected."); await expect(lp1.getByTestId("vehicle-status-charger")).toHaveText("Connected.");
await expect(lp1.getByTestId("vehicle-status-planstart")).toHaveText(/tomorrow .*/); await expect(lp1.getByTestId("vehicle-status-planstart")).toHaveText(/tomorrow .*/);
await expect(lp1.getByTestId("plan-marker")).toBeVisible(); await expect(lp1.getByTestId("plan-marker")).toBeVisible();
await expect(lp1.getByTestId("charging-plan").getByRole("button")).toHaveText( await expect(lp1.getByTestId("charging-plan").getByRole("button")).toHaveText(
"tomorrow 09:3080%" ["tomorrow 09:30", "80%"].join("")
); );
await lp1.getByTestId("charging-plan").getByRole("button").click(); await lp1.getByTestId("charging-plan").getByRole("button").click();
await expect(page.getByTestId("static-plan-soc")).toHaveValue("80"); await expect(page.getByTestId("static-plan-soc")).toHaveValue("80");
@ -484,26 +482,17 @@ test.describe("repeating", async () => {
await modal.getByRole("checkbox", { name: "Select all" }).check(); await modal.getByRole("checkbox", { name: "Select all" }).check();
await modal.getByTestId("repeating-plan-time").fill("11:11"); await modal.getByTestId("repeating-plan-time").fill("11:11");
// switch between previews // with multiple plans, preview shows first plan
await page.waitForLoadState("networkidle"); await page.waitForLoadState("networkidle");
await modal await expect(modal.getByTestId("plan-preview-title")).toHaveText("Preview plan #1");
.getByTestId("plan-preview-title")
.getByRole("combobox")
.selectOption("Preview plan #2");
await expect(modal.getByTestId("target-text")).toContainText("11:11");
await modal
.getByTestId("plan-preview-title")
.getByRole("combobox")
.selectOption("Preview plan #1");
await expect(modal.getByTestId("target-text")).toContainText("09:00"); await expect(modal.getByTestId("target-text")).toContainText("09:00");
// activate #1 // activate #1 - should show next plan #1
await modal.getByTestId("static-plan-active").click(); await modal.getByTestId("static-plan-active").click();
await expect(modal.getByTestId("plan-preview-title")).toHaveText("Next plan #1"); await expect(modal.getByTestId("plan-preview-title")).toHaveText("Next plan #1");
await expect(modal.getByTestId("target-text")).toContainText("09:00"); await expect(modal.getByTestId("target-text")).toContainText("09:00");
// activate #2 // deactivate #1, activate #2 - should show next plan #2
await modal.getByTestId("static-plan-active").click(); await modal.getByTestId("static-plan-active").click();
await modal.getByTestId("repeating-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("plan-preview-title")).toHaveText("Next plan #2");
@ -511,9 +500,7 @@ test.describe("repeating", async () => {
// back to preview if no active plan // back to preview if no active plan
await modal.getByTestId("repeating-plan-active").click(); await modal.getByTestId("repeating-plan-active").click();
await expect(modal.getByTestId("plan-preview-title").locator("option:checked")).toHaveText( await expect(modal.getByTestId("plan-preview-title")).toHaveText("Preview plan #1");
"Preview plan #1"
);
await expect(modal.getByTestId("target-text")).toContainText("9:00"); await expect(modal.getByTestId("target-text")).toContainText("9:00");
}); });
@ -663,11 +650,9 @@ test.describe("repeating", async () => {
await plan.getByRole("checkbox", { name: "Select all" }).click(); // uncheck all await plan.getByRole("checkbox", { name: "Select all" }).click(); // uncheck all
await plan.getByRole("checkbox", { name: tomorrow }).check(); await plan.getByRole("checkbox", { name: tomorrow }).check();
await plan.getByTestId("repeating-plan-time").fill("09:20"); await plan.getByTestId("repeating-plan-time").fill("09:20");
await plan.getByTestId("repeating-plan-precondition-lg-toggle").click(); await modal.getByRole("button", { name: "Strategy settings" }).click();
await plan await expect(modal.getByLabel("Late Charging")).toBeVisible();
.getByTestId("repeating-plan-precondition-lg-select") await modal.getByLabel("Late Charging").selectOption("2 hours");
.getByRole("combobox")
.selectOption("2 hours");
await plan.getByTestId("repeating-plan-active").click(); await plan.getByTestId("repeating-plan-active").click();
await expect(modal.getByTestId("plan-preview-title")).toHaveText("Next plan #2"); await expect(modal.getByTestId("plan-preview-title")).toHaveText("Next plan #2");
await expect(modal.getByTestId("target-text")).toContainText("09:20"); await expect(modal.getByTestId("target-text")).toContainText("09:20");
@ -689,26 +674,55 @@ test.describe("repeating", async () => {
await expect(modal.getByTestId("plan-entry")).toHaveCount(2); await expect(modal.getByTestId("plan-entry")).toHaveCount(2);
await expect(modal.getByTestId("plan-preview-title")).toHaveText("Next plan #2"); await expect(modal.getByTestId("plan-preview-title")).toHaveText("Next plan #2");
await expect(modal.getByTestId("target-text")).toContainText("09:20"); await expect(modal.getByTestId("target-text")).toContainText("09:20");
await expect(modal.getByTestId("repeating-plan-precondition-lg-toggle")).toBeChecked(); await expect(modal.getByLabel("Late Charging")).toHaveValue("7200");
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 // 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.describe("plan strategy", async () => {
test("only if dynamic tariff exists", async ({ page }) => { test("only if dynamic tariff exists", async ({ page }) => {
await restart(CONFIG_NO_TARIFF); await restart(CONFIG_NO_TARIFF);
await page.goto("/"); await page.goto("/");
const lp1 = await page.getByTestId("loadpoint").first(); const lp1 = await page.getByTestId("loadpoint").first();
await lp1.getByTestId("charging-plan").getByRole("button", { name: "none" }).click(); await lp1.getByTestId("charging-plan").getByRole("button", { name: "none" }).click();
await expect(page.getByTestId("static-plan-active")).toBeVisible(); const modal = page.getByTestId("charging-plan-modal");
await expect(page.getByTestId("static-plan-precondition-lg-toggle")).not.toBeVisible(); await expect(modal.getByTestId("static-plan-active")).toBeVisible();
await expect(page.getByTestId("static-plan-precondition-lg-select")).not.toBeVisible(); // Strategy toggle should not be visible when no dynamic tariff exists
await expect(modal.getByRole("button", { name: "Strategy settings" })).not.toBeVisible();
});
// verify small viewport test("visible and functional on mobile", async ({ page }) => {
await page.goto("/");
// Set mobile viewport
await page.setViewportSize(mobile); await page.setViewportSize(mobile);
await expect(page.getByTestId("static-plan-precondition-select")).not.toBeVisible();
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 = page.getByTestId("charging-plan-modal");
// Strategy toggle should be visible on mobile
await expect(modal.getByRole("button", { name: "Strategy settings" })).toBeVisible();
// Open strategy panel
await modal.getByRole("button", { name: "Strategy settings" }).click();
// Strategy controls should be visible and functional
await expect(modal.getByLabel("Optimization")).toBeVisible();
await expect(modal.getByLabel("Late Charging")).toBeVisible();
// Test changing strategy on mobile
await modal.getByLabel("Optimization").selectOption("true");
await modal.getByLabel("Late Charging").selectOption("1 hour");
// Verify the selections work
await expect(modal.getByLabel("Optimization")).toHaveValue("true");
await expect(modal.getByLabel("Late Charging")).toHaveValue("3600");
}); });
}); });