Planner: add precondition phase before departure (#20431)
This commit is contained in:
parent
c4be8e918d
commit
832012ef15
41 changed files with 746 additions and 222 deletions
11
api/plans.go
11
api/plans.go
|
|
@ -1,9 +1,10 @@
|
|||
package api
|
||||
|
||||
type RepeatingPlanStruct struct {
|
||||
Weekdays []int `json:"weekdays"` // 0-6 (Sunday-Saturday)
|
||||
Time string `json:"time"` // HH:MM
|
||||
Tz string `json:"tz"` // timezone in IANA format
|
||||
Soc int `json:"soc"`
|
||||
Active bool `json:"active"`
|
||||
Weekdays []int `json:"weekdays"` // 0-6 (Sunday-Saturday)
|
||||
Time string `json:"time"` // HH:MM
|
||||
Tz string `json:"tz"` // timezone in IANA format
|
||||
Soc int `json:"soc"` // target soc
|
||||
Precondition int64 `json:"precondition"` // precondition duration in seconds
|
||||
Active bool `json:"active"` // active flag
|
||||
}
|
||||
|
|
|
|||
|
|
@ -115,7 +115,7 @@ import { optionStep, fmtEnergy } from "../../utils/energyOptions.js";
|
|||
import { defineComponent, type PropType } from "vue";
|
||||
import type { CURRENCY, Timeout, Vehicle } from "assets/js/types/evcc.js";
|
||||
import type { StaticPlan, StaticSocPlan, StaticEnergyPlan, RepeatingPlan } from "./types.js";
|
||||
|
||||
import type { Forecast } from "../../utils/forecast.ts";
|
||||
const ONE_MINUTE = 60 * 1000;
|
||||
|
||||
export default defineComponent({
|
||||
|
|
@ -139,6 +139,7 @@ export default defineComponent({
|
|||
planEnergy: Number,
|
||||
planTime: String,
|
||||
planTimeUnreachable: Boolean,
|
||||
planPrecondition: { type: Number, default: 0 },
|
||||
planOverrun: Number,
|
||||
rangePerSoc: Number,
|
||||
smartCostType: String,
|
||||
|
|
@ -149,6 +150,7 @@ export default defineComponent({
|
|||
capacity: Number,
|
||||
vehicleSoc: Number,
|
||||
vehicleLimitSoc: Number,
|
||||
forecast: Object as PropType<Forecast>,
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
|
|
@ -179,12 +181,20 @@ export default defineComponent({
|
|||
if (this.socBasedPlanning) {
|
||||
const plan = this.vehicle?.plan as StaticSocPlan;
|
||||
if (plan) {
|
||||
return { soc: plan.soc, time: new Date(plan.time) };
|
||||
return {
|
||||
soc: plan.soc,
|
||||
time: new Date(plan.time),
|
||||
precondition: plan.precondition,
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
if (this.planEnergy && this.planTime) {
|
||||
return { energy: this.planEnergy, time: new Date(this.planTime) };
|
||||
return {
|
||||
energy: this.planEnergy,
|
||||
time: new Date(this.planTime),
|
||||
precondition: this.planPrecondition,
|
||||
};
|
||||
}
|
||||
return null;
|
||||
},
|
||||
|
|
@ -304,12 +314,15 @@ export default defineComponent({
|
|||
},
|
||||
updateStaticPlan(plan: StaticPlan): void {
|
||||
const timeISO = plan.time.toISOString();
|
||||
const params = this.socBasedPlanning ? { precondition: plan.precondition } : undefined;
|
||||
if (this.socBasedPlanning) {
|
||||
const p = plan as StaticSocPlan;
|
||||
api.post(`${this.apiVehicle}plan/soc/${p.soc}/${timeISO}`);
|
||||
api.post(`${this.apiVehicle}plan/soc/${p.soc}/${timeISO}`, null, { params });
|
||||
} else {
|
||||
const p = plan as StaticEnergyPlan;
|
||||
api.post(`${this.apiLoadpoint}plan/energy/${p.energy}/${timeISO}`);
|
||||
api.post(`${this.apiLoadpoint}plan/energy/${p.energy}/${timeISO}`, null, {
|
||||
params,
|
||||
});
|
||||
}
|
||||
},
|
||||
removeStaticPlan(): void {
|
||||
|
|
|
|||
|
|
@ -14,22 +14,27 @@
|
|||
|
||||
<div v-if="showHeader" class="row d-none d-lg-flex mb-2">
|
||||
<div class="plan-id d-none d-lg-flex"></div>
|
||||
<div class="col-6 col-lg-3">
|
||||
<div class="col-3">
|
||||
<label :for="formId('weekdays')">
|
||||
{{ $t("main.chargingPlan.weekdays") }}
|
||||
</label>
|
||||
</div>
|
||||
<div class="col-6 col-lg-2">
|
||||
<div class="col-2">
|
||||
<label :for="formId('time')">
|
||||
{{ $t("main.chargingPlan.time") }}
|
||||
</label>
|
||||
</div>
|
||||
<div class="col-6 col-lg-3">
|
||||
<div :class="showPrecondition ? 'col-2' : 'col-3'">
|
||||
<label :for="formId('goal')">
|
||||
{{ $t("main.chargingPlan.goal") }}
|
||||
</label>
|
||||
</div>
|
||||
<div class="col-6 col-lg-1">
|
||||
<div v-if="showPrecondition" class="col-1">
|
||||
<label :for="formId('precondition')">
|
||||
{{ $t("main.chargingPlan.preconditionShort") }}
|
||||
</label>
|
||||
</div>
|
||||
<div class="col-1">
|
||||
<label :for="formId('active')"> {{ $t("main.chargingPlan.active") }} </label>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -76,7 +81,7 @@
|
|||
{{ $t("main.chargingPlan.goal") }}
|
||||
</label>
|
||||
</div>
|
||||
<div class="col-7 col-lg-3 mb-2 mb-lg-0">
|
||||
<div :class="['col-7', showPrecondition ? 'col-lg-2' : 'col-lg-3', 'mb-2', 'mb-lg-0']">
|
||||
<select
|
||||
:id="formId('goal')"
|
||||
v-model="selectedSoc"
|
||||
|
|
@ -89,6 +94,21 @@
|
|||
</option>
|
||||
</select>
|
||||
</div>
|
||||
<div v-if="showPrecondition" class="col-5 d-lg-none col-form-label">
|
||||
<label :for="formId('precondition')">
|
||||
{{ $t("main.chargingPlan.preconditionLong") }}
|
||||
</label>
|
||||
</div>
|
||||
<div
|
||||
v-if="showPrecondition"
|
||||
class="col-7 col-lg-1 mb-2 mb-lg-0 d-flex align-items-center"
|
||||
>
|
||||
<PreconditionSelect
|
||||
:id="formId('precondition')"
|
||||
v-model="selectedPrecondition"
|
||||
testid="repeating-plan-precondition"
|
||||
/>
|
||||
</div>
|
||||
<div class="col-5 d-lg-none col-form-label">
|
||||
<label :for="formId('active')">
|
||||
{{ $t("main.chargingPlan.active") }}
|
||||
|
|
@ -132,6 +152,15 @@
|
|||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Large screen precondition description -->
|
||||
<div class="plan-id-inset">
|
||||
<PreconditionSelect
|
||||
:id="formId('precondition')"
|
||||
v-model="selectedPrecondition"
|
||||
testid="repeating-plan-precondition"
|
||||
description-lg-only
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
|
@ -141,12 +170,13 @@ import { distanceUnit } from "../../units.js";
|
|||
import MultiSelect from "../Helper/MultiSelect.vue";
|
||||
import formatter from "../../mixins/formatter.js";
|
||||
import deepEqual from "../../utils/deepEqual.js";
|
||||
import PreconditionSelect from "./PreconditionSelect.vue";
|
||||
import type { SelectOption } from "../../types/evcc";
|
||||
import { defineComponent, type PropType } from "vue";
|
||||
|
||||
export default defineComponent({
|
||||
name: "ChargingPlanRepeatingSettings",
|
||||
components: { MultiSelect },
|
||||
components: { MultiSelect, PreconditionSelect },
|
||||
mixins: [formatter],
|
||||
props: {
|
||||
number: Number,
|
||||
|
|
@ -154,10 +184,12 @@ export default defineComponent({
|
|||
time: String,
|
||||
tz: String,
|
||||
soc: Number,
|
||||
precondition: Number,
|
||||
showHeader: Boolean,
|
||||
active: Boolean,
|
||||
rangePerSoc: Number,
|
||||
formIdPrefix: String,
|
||||
showPrecondition: Boolean,
|
||||
},
|
||||
emits: ["updated", "removed"],
|
||||
data() {
|
||||
|
|
@ -166,6 +198,7 @@ export default defineComponent({
|
|||
selectedTime: this.time,
|
||||
selectedSoc: this.soc,
|
||||
selectedActive: this.active,
|
||||
selectedPrecondition: this.precondition,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
|
|
@ -174,7 +207,8 @@ export default defineComponent({
|
|||
!deepEqual(this.weekdays, this.selectedWeekdays) ||
|
||||
this.time !== this.selectedTime ||
|
||||
this.soc !== this.selectedSoc ||
|
||||
this.active !== this.selectedActive
|
||||
this.active !== this.selectedActive ||
|
||||
this.precondition !== this.selectedPrecondition
|
||||
);
|
||||
},
|
||||
showApply(): boolean {
|
||||
|
|
@ -208,6 +242,9 @@ export default defineComponent({
|
|||
active(newValue: boolean) {
|
||||
this.selectedActive = newValue;
|
||||
},
|
||||
precondition(newValue: number) {
|
||||
this.selectedPrecondition = newValue;
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
id(): number {
|
||||
|
|
@ -231,6 +268,7 @@ export default defineComponent({
|
|||
soc: this.selectedSoc,
|
||||
tz: this.tz,
|
||||
active: this.selectedActive,
|
||||
precondition: this.selectedPrecondition,
|
||||
};
|
||||
|
||||
if (forceSave || !this.selectedActive) {
|
||||
|
|
@ -241,6 +279,9 @@ export default defineComponent({
|
|||
});
|
||||
</script>
|
||||
<style scoped>
|
||||
.plan-id-inset {
|
||||
margin-left: 2.5rem;
|
||||
}
|
||||
.plan-id {
|
||||
width: 2.5rem;
|
||||
color: var(--evcc-gray);
|
||||
|
|
|
|||
|
|
@ -11,23 +11,28 @@
|
|||
</h5>
|
||||
|
||||
<div class="row d-none d-lg-flex mb-2">
|
||||
<div v-if="multiplePlans" class="plan-id d-none d-lg-flex"></div>
|
||||
<div class="col-6" :class="multiplePlans ? 'col-lg-3' : 'col-lg-4'">
|
||||
<div v-if="multiplePlans" class="plan-id d-flex"></div>
|
||||
<div class="col-3">
|
||||
<label :for="formId('day')">
|
||||
{{ $t("main.chargingPlan.day") }}
|
||||
</label>
|
||||
</div>
|
||||
<div class="col-6 col-lg-2">
|
||||
<div class="col-2">
|
||||
<label :for="formId('time')">
|
||||
{{ $t("main.chargingPlan.time") }}
|
||||
</label>
|
||||
</div>
|
||||
<div class="col-6 col-lg-3">
|
||||
<div :class="showPrecondition ? 'col-2' : 'col-3'">
|
||||
<label :for="formId('goal')">
|
||||
{{ $t("main.chargingPlan.goal") }}
|
||||
</label>
|
||||
</div>
|
||||
<div class="col-6 col-lg-1">
|
||||
<div v-if="showPrecondition" class="col-1">
|
||||
<label :for="formId('precondition')">
|
||||
{{ $t("main.chargingPlan.preconditionShort") }}
|
||||
</label>
|
||||
</div>
|
||||
<div class="col-1">
|
||||
<label :for="formId('active')"> {{ $t("main.chargingPlan.active") }} </label>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -43,7 +48,7 @@
|
|||
{{ $t("main.chargingPlan.day") }}
|
||||
</label>
|
||||
</div>
|
||||
<div :class="['col-7', multiplePlans ? 'col-lg-3' : 'col-lg-4', 'mb-2', 'mb-lg-0']">
|
||||
<div class="col-7 col-lg-3 mb-2 mb-lg-0">
|
||||
<select
|
||||
:id="formId('day')"
|
||||
v-model="selectedDay"
|
||||
|
|
@ -78,7 +83,7 @@
|
|||
{{ $t("main.chargingPlan.goal") }}
|
||||
</label>
|
||||
</div>
|
||||
<div class="col-7 col-lg-3 mb-2 mb-lg-0">
|
||||
<div :class="['col-7', showPrecondition ? 'col-lg-2' : 'col-lg-3', 'mb-2', 'mb-lg-0']">
|
||||
<select
|
||||
v-if="socBasedPlanning"
|
||||
:id="formId('goal')"
|
||||
|
|
@ -104,13 +109,28 @@
|
|||
</option>
|
||||
</select>
|
||||
</div>
|
||||
<div v-if="showPrecondition" class="col-5 d-lg-none col-form-label">
|
||||
<label :for="formId('precondition')">
|
||||
{{ $t("main.chargingPlan.preconditionLong") }}
|
||||
</label>
|
||||
</div>
|
||||
<div
|
||||
v-if="showPrecondition"
|
||||
class="col-7 col-lg-1 mb-2 mb-lg-0 d-flex align-items-center"
|
||||
>
|
||||
<PreconditionSelect
|
||||
:id="formId('precondition')"
|
||||
v-model="selectedPrecondition"
|
||||
testid="static-plan-precondition"
|
||||
/>
|
||||
</div>
|
||||
<div class="col-5 d-lg-none col-form-label">
|
||||
<label :for="formId('active')">
|
||||
{{ $t("main.chargingPlan.active") }}
|
||||
</label>
|
||||
</div>
|
||||
<div class="col-2 col-lg-1 d-flex align-items-center">
|
||||
<div class="form-check form-switch">
|
||||
<div class="col-3 col-lg-1 d-flex align-items-center">
|
||||
<div class="form-check form-switch my-1">
|
||||
<input
|
||||
:id="formId('active')"
|
||||
class="form-check-input"
|
||||
|
|
@ -124,7 +144,9 @@
|
|||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-5 col-lg-2 d-flex align-items-center">
|
||||
<div
|
||||
class="col-4 col-lg-2 d-flex align-items-center justify-content-end justify-content-lg-start"
|
||||
>
|
||||
<button
|
||||
v-if="dataChanged && !isNew"
|
||||
type="button"
|
||||
|
|
@ -138,6 +160,15 @@
|
|||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Large screen precondition description -->
|
||||
<div :class="multiplePlans ? 'plan-id-insert' : ''">
|
||||
<PreconditionSelect
|
||||
:id="formId('precondition')"
|
||||
v-model="selectedPrecondition"
|
||||
testid="static-plan-precondition"
|
||||
description-lg-only
|
||||
/>
|
||||
</div>
|
||||
<p class="mb-0" data-testid="plan-entry-warnings">
|
||||
<span v-if="timeInThePast" class="d-block text-danger my-2">
|
||||
{{ $t("main.targetCharge.targetIsInThePast") }}
|
||||
|
|
@ -153,6 +184,7 @@ import { distanceUnit } from "../../units.js";
|
|||
import formatter from "../../mixins/formatter.js";
|
||||
import { energyOptions } from "../../utils/energyOptions.js";
|
||||
import { defineComponent } from "vue";
|
||||
import PreconditionSelect from "./PreconditionSelect.vue";
|
||||
|
||||
const LAST_TARGET_TIME_KEY = "last_target_time";
|
||||
const LAST_SOC_GOAL_KEY = "last_soc_goal";
|
||||
|
|
@ -161,6 +193,7 @@ const DEFAULT_TARGET_TIME = "7:00";
|
|||
|
||||
export default defineComponent({
|
||||
name: "ChargingPlanStaticSettings",
|
||||
components: { PreconditionSelect },
|
||||
mixins: [formatter],
|
||||
props: {
|
||||
id: [String, Number],
|
||||
|
|
@ -172,6 +205,8 @@ export default defineComponent({
|
|||
capacity: Number,
|
||||
socBasedPlanning: Boolean,
|
||||
multiplePlans: Boolean,
|
||||
precondition: Number,
|
||||
showPrecondition: Boolean,
|
||||
},
|
||||
emits: ["static-plan-updated", "static-plan-removed", "plan-preview"],
|
||||
data() {
|
||||
|
|
@ -181,6 +216,7 @@ export default defineComponent({
|
|||
selectedSoc: this.soc,
|
||||
selectedEnergy: this.energy,
|
||||
active: false,
|
||||
selectedPrecondition: 0,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
|
|
@ -215,6 +251,7 @@ export default defineComponent({
|
|||
energy: this.energy,
|
||||
day: this.fmtDayString(t),
|
||||
time: this.fmtTimeString(t),
|
||||
precondition: this.precondition,
|
||||
};
|
||||
},
|
||||
dataChanged() {
|
||||
|
|
@ -224,7 +261,8 @@ export default defineComponent({
|
|||
const goalChanged = this.socBasedPlanning
|
||||
? this.originalData.soc != this.selectedSoc
|
||||
: this.originalData.energy != this.selectedEnergy;
|
||||
return dateChanged || goalChanged;
|
||||
const preconditionChanged = this.originalData.precondition != this.selectedPrecondition;
|
||||
return dateChanged || goalChanged || preconditionChanged;
|
||||
},
|
||||
isNew() {
|
||||
return !this.time && (!this.soc || !this.energy);
|
||||
|
|
@ -251,6 +289,12 @@ export default defineComponent({
|
|||
isNew(value) {
|
||||
this.active = !value;
|
||||
},
|
||||
precondition(value) {
|
||||
this.selectedPrecondition = value;
|
||||
},
|
||||
selectedPrecondition() {
|
||||
this.preview();
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
this.initInputFields();
|
||||
|
|
@ -325,6 +369,7 @@ export default defineComponent({
|
|||
time: this.selectedDate,
|
||||
soc: this.selectedSoc,
|
||||
energy: this.selectedEnergy,
|
||||
precondition: this.selectedPrecondition,
|
||||
});
|
||||
},
|
||||
preview(force = false) {
|
||||
|
|
@ -335,6 +380,7 @@ export default defineComponent({
|
|||
time: this.selectedDate,
|
||||
soc: this.selectedSoc,
|
||||
energy: this.selectedEnergy,
|
||||
precondition: this.selectedPrecondition,
|
||||
});
|
||||
},
|
||||
toggle(e: Event) {
|
||||
|
|
@ -367,6 +413,9 @@ export default defineComponent({
|
|||
});
|
||||
</script>
|
||||
<style scoped>
|
||||
.plan-id-insert {
|
||||
margin-left: 2.5rem;
|
||||
}
|
||||
.plan-id {
|
||||
width: 2.5rem;
|
||||
color: var(--evcc-gray);
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@
|
|||
:formIdPrefix="formIdPrefix"
|
||||
v-bind="plan"
|
||||
:rangePerSoc="rangePerSoc"
|
||||
:showPrecondition="showPrecondition"
|
||||
@updated="updatePlan(index, $event)"
|
||||
@removed="removePlan(index)"
|
||||
/>
|
||||
|
|
@ -48,6 +49,7 @@ export default defineComponent({
|
|||
id: [Number, String],
|
||||
rangePerSoc: Number,
|
||||
plans: { type: Array as PropType<RepeatingPlan[]>, default: () => [] },
|
||||
showPrecondition: Boolean,
|
||||
},
|
||||
emits: ["updated"],
|
||||
computed: {
|
||||
|
|
@ -62,6 +64,7 @@ export default defineComponent({
|
|||
weekdays: DEFAULT_WEEKDAYS,
|
||||
time: DEFAULT_TARGET_TIME,
|
||||
soc: DEFAULT_TARGET_SOC,
|
||||
precondition: 0,
|
||||
active: false,
|
||||
tz: this.timezone(),
|
||||
};
|
||||
|
|
|
|||
|
|
@ -10,7 +10,8 @@
|
|||
:range-per-soc="rangePerSoc"
|
||||
:soc-per-kwh="socPerKwh"
|
||||
:soc-based-planning="socBasedPlanning"
|
||||
:multiplePlans="multiplePlans"
|
||||
:multiple-plans="multiplePlans"
|
||||
:show-precondition="showPrecondition"
|
||||
@static-plan-updated="(data) => updateStaticPlan({ index: 0, ...data })"
|
||||
@static-plan-removed="() => removeStaticPlan(0)"
|
||||
@plan-preview="previewStaticPlan"
|
||||
|
|
@ -29,6 +30,7 @@
|
|||
:id="id"
|
||||
:rangePerSoc="rangePerSoc"
|
||||
:plans="repeatingPlans"
|
||||
:show-precondition="showPrecondition"
|
||||
@updated="updateRepeatingPlans"
|
||||
/>
|
||||
</div>
|
||||
|
|
@ -59,8 +61,8 @@
|
|||
</span>
|
||||
</div>
|
||||
</h5>
|
||||
<ChargingPlanWarnings v-bind="chargingPlanWarningsProps" />
|
||||
<ChargingPlanPreview v-bind="chargingPlanPreviewProps" />
|
||||
<ChargingPlanWarnings v-bind="chargingPlanWarningsProps" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
|
@ -72,12 +74,12 @@ import RepeatingSettings from "./PlansRepeatingSettings.vue";
|
|||
import Warnings from "./Warnings.vue";
|
||||
import formatter from "../../mixins/formatter.js";
|
||||
import collector from "../../mixins/collector.js";
|
||||
import api, { allowClientError } from "../../api.js";
|
||||
import api from "../../api.js";
|
||||
import CustomSelect from "../Helper/CustomSelect.vue";
|
||||
import deepEqual from "../../utils/deepEqual.js";
|
||||
import convertRates from "../../utils/convertRates";
|
||||
import { defineComponent, type PropType } from "vue";
|
||||
import type { Vehicle, PartialBy, Timeout, Tariff, SelectOption, CURRENCY } from "../../types/evcc";
|
||||
import type { Vehicle, PartialBy, Timeout, SelectOption, CURRENCY } from "../../types/evcc";
|
||||
import type {
|
||||
StaticPlan,
|
||||
RepeatingPlan,
|
||||
|
|
@ -86,8 +88,7 @@ import type {
|
|||
StaticEnergyPlan,
|
||||
PlanResponse,
|
||||
} from "./types.js";
|
||||
|
||||
const TARIFF_CACHE_TIME = 300000; // 5 minutes
|
||||
import type { Forecast } from "../../utils/forecast.ts";
|
||||
|
||||
export default defineComponent({
|
||||
name: "ChargingPlansSettings",
|
||||
|
|
@ -118,12 +119,12 @@ export default defineComponent({
|
|||
vehicle: Object as PropType<Vehicle>,
|
||||
vehicleLimitSoc: Number,
|
||||
planOverrun: Number,
|
||||
forecast: Object as PropType<Forecast>,
|
||||
},
|
||||
emits: ["static-plan-removed", "static-plan-updated", "repeating-plans-updated"],
|
||||
data() {
|
||||
return {
|
||||
staticPlanPreview: {} as StaticPlan,
|
||||
tariff: {} as Tariff,
|
||||
plan: {} as PlanWrapper,
|
||||
activeTab: "time",
|
||||
debounceTimer: null as Timeout,
|
||||
|
|
@ -145,7 +146,8 @@ export default defineComponent({
|
|||
return this.collectProps(Warnings);
|
||||
},
|
||||
chargingPlanPreviewProps(): any {
|
||||
const { rates } = this.tariff;
|
||||
const forecastSlots = this.forecast?.planner || [];
|
||||
const rates = convertRates(forecastSlots);
|
||||
const { duration, plan, power, planTime } = this.plan;
|
||||
const targetTime = planTime ? new Date(planTime) : null;
|
||||
const { currency, smartCostType } = this;
|
||||
|
|
@ -177,6 +179,12 @@ export default defineComponent({
|
|||
nextPlanTitle(): string {
|
||||
return `${this.$t("main.targetCharge.nextPlan")} #${this.nextPlanId}`;
|
||||
},
|
||||
showPrecondition(): boolean {
|
||||
// only show option if planner forecast has different values
|
||||
const slots = this.forecast?.planner || [];
|
||||
const values = new Set(slots.map(({ value }) => value));
|
||||
return values.size > 1;
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
effectivePlanTime(newValue: string) {
|
||||
|
|
@ -223,7 +231,6 @@ export default defineComponent({
|
|||
}
|
||||
},
|
||||
async updateActivePlan(): Promise<void> {
|
||||
await this.updateTariff();
|
||||
try {
|
||||
const res = await this.apiFetchPlan(`loadpoints/${this.id}/plan`);
|
||||
this.plan = res?.data.result ?? ({} as PlanWrapper);
|
||||
|
|
@ -234,8 +241,10 @@ export default defineComponent({
|
|||
},
|
||||
async fetchStaticPreviewSoc(plan: StaticSocPlan): Promise<PlanResponse | undefined> {
|
||||
const timeISO = plan.time.toISOString();
|
||||
const params = plan.precondition ? { precondition: plan.precondition } : undefined;
|
||||
return await this.apiFetchPlan(
|
||||
`loadpoints/${this.id}/plan/static/preview/soc/${plan.soc}/${timeISO}`
|
||||
`loadpoints/${this.id}/plan/static/preview/soc/${plan.soc}/${timeISO}`,
|
||||
params
|
||||
);
|
||||
},
|
||||
async fetchRepeatingPreview(
|
||||
|
|
@ -251,10 +260,14 @@ export default defineComponent({
|
|||
`loadpoints/${this.id}/plan/static/preview/energy/${plan.energy}/${timeISO}`
|
||||
);
|
||||
},
|
||||
async apiFetchPlan(url: string): Promise<PlanResponse | undefined> {
|
||||
async apiFetchPlan(
|
||||
url: string,
|
||||
params?: Record<string, unknown>
|
||||
): Promise<PlanResponse | undefined> {
|
||||
try {
|
||||
const res = (await api.get(url, {
|
||||
validateStatus: (code) => [200, 404].includes(code),
|
||||
params,
|
||||
})) as PlanResponse;
|
||||
if (res.status === 404) {
|
||||
return { data: { result: {} as PlanWrapper } } as PlanResponse;
|
||||
|
|
@ -269,8 +282,6 @@ export default defineComponent({
|
|||
// only show preview if no plan is active
|
||||
if (!this.noActivePlan) return;
|
||||
|
||||
await this.updateTariff();
|
||||
|
||||
try {
|
||||
let planRes: PlanResponse | undefined = undefined;
|
||||
|
||||
|
|
@ -282,12 +293,14 @@ export default defineComponent({
|
|||
planRes = await this.fetchStaticPreviewSoc({
|
||||
soc: plan.soc,
|
||||
time: plan.time,
|
||||
precondition: plan.precondition,
|
||||
});
|
||||
} else {
|
||||
plan = plan as StaticEnergyPlan;
|
||||
planRes = await this.fetchStaticPreviewEnergy({
|
||||
energy: plan.energy,
|
||||
time: plan.time,
|
||||
precondition: plan.precondition,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
|
|
@ -296,33 +309,23 @@ export default defineComponent({
|
|||
if (!plan) {
|
||||
return;
|
||||
}
|
||||
const { weekdays, soc, time, tz } = plan;
|
||||
const { weekdays, soc, time, tz, precondition } = plan;
|
||||
if (weekdays.length === 0) {
|
||||
return;
|
||||
}
|
||||
planRes = await this.fetchRepeatingPreview({ weekdays, soc, time, tz });
|
||||
planRes = await this.fetchRepeatingPreview({
|
||||
weekdays,
|
||||
soc,
|
||||
time,
|
||||
tz,
|
||||
precondition,
|
||||
});
|
||||
}
|
||||
this.plan = planRes?.data.result ?? ({} as PlanWrapper);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
},
|
||||
async updateTariff(): Promise<void> {
|
||||
// cache tariff for 5 minutes
|
||||
if (
|
||||
this.tariff?.lastUpdate &&
|
||||
Date.now() - this.tariff.lastUpdate.getTime() <= TARIFF_CACHE_TIME
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const res = await api.get(`tariff/planner`, allowClientError);
|
||||
|
||||
this.tariff = {
|
||||
rates: res.status === 404 ? [] : convertRates(res.data.result.rates),
|
||||
lastUpdate: new Date(),
|
||||
};
|
||||
},
|
||||
async updatePlanPreviewDebounced(): Promise<void> {
|
||||
if (!this.debounceTimer) {
|
||||
await this.updatePreviewPlan();
|
||||
|
|
|
|||
134
assets/js/components/ChargingPlans/PreconditionSelect.vue
Normal file
134
assets/js/components/ChargingPlans/PreconditionSelect.vue
Normal file
|
|
@ -0,0 +1,134 @@
|
|||
<template>
|
||||
<!-- small screen (select, description), large screen (checkbox only) -->
|
||||
<template v-if="!descriptionLgOnly">
|
||||
<div class="form-check d-none d-lg-block" :style="{ padding: '2px' }">
|
||||
<input
|
||||
:id="id"
|
||||
class="form-check-input d-none d-lg-block"
|
||||
type="checkbox"
|
||||
tabindex="0"
|
||||
:data-testid="`${testid}-lg-toggle`"
|
||||
:checked="enabled"
|
||||
@change="toggle"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="d-flex flex-column w-100">
|
||||
<select
|
||||
:id="`${id}-sm`"
|
||||
v-model="localValue"
|
||||
class="form-select mx-0 d-lg-none"
|
||||
:data-testid="`${testid}-select`"
|
||||
@change="onChange"
|
||||
>
|
||||
<option :value="0">
|
||||
{{ $t("main.chargingPlan.preconditionOptionNo") }}
|
||||
</option>
|
||||
<option v-for="opt in options" :key="opt.value" :value="opt.value">
|
||||
{{ opt.name }}
|
||||
</option>
|
||||
</select>
|
||||
|
||||
<small v-if="enabled" class="d-block d-lg-none mt-1 mb-2">
|
||||
{{ $t("main.chargingPlan.preconditionDescription", { duration: valueFmt }) }}
|
||||
</small>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- large screen (description with select) -->
|
||||
<template v-else>
|
||||
<p v-if="enabled" class="m-2 d-none d-lg-block">
|
||||
<strong>{{ $t("main.chargingPlan.preconditionLong") }}: </strong>
|
||||
<i18n-t tag="span" scope="global" keypath="main.chargingPlan.preconditionDescription">
|
||||
<template #duration>
|
||||
<CustomSelect
|
||||
:id="`${id}-lg`"
|
||||
class="d-inline-flex"
|
||||
:options="options"
|
||||
:selected="localValue"
|
||||
:data-testid="`${testid}-lg-select`"
|
||||
@change="onChange"
|
||||
>
|
||||
<u>{{ valueFmt }}</u>
|
||||
</CustomSelect>
|
||||
</template>
|
||||
</i18n-t>
|
||||
</p>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent } from "vue";
|
||||
import CustomSelect from "../Helper/CustomSelect.vue";
|
||||
import formatter from "../../mixins/formatter.js";
|
||||
|
||||
export default defineComponent({
|
||||
name: "PreconditionSelect",
|
||||
components: { CustomSelect },
|
||||
mixins: [formatter],
|
||||
props: {
|
||||
id: String,
|
||||
modelValue: { type: Number, default: 0 },
|
||||
testid: String,
|
||||
descriptionLgOnly: Boolean,
|
||||
},
|
||||
emits: ["update:modelValue"],
|
||||
data() {
|
||||
return {
|
||||
localValue: this.modelValue,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
enabled() {
|
||||
return this.localValue > 0;
|
||||
},
|
||||
options() {
|
||||
const HOUR = 60 * 60;
|
||||
const HALF_HOUR = 0.5 * HOUR;
|
||||
const ONE_HOUR = 1 * HOUR;
|
||||
const TWO_HOURS = 2 * HOUR;
|
||||
const EVERYTHING = 7 * 24 * HOUR;
|
||||
|
||||
const options = [HALF_HOUR, ONE_HOUR, TWO_HOURS, EVERYTHING];
|
||||
|
||||
// support custom values (via API)
|
||||
if (this.localValue && !options.includes(this.localValue)) {
|
||||
options.push(this.localValue);
|
||||
}
|
||||
|
||||
return options.map((s) => ({
|
||||
value: s,
|
||||
name:
|
||||
s === EVERYTHING
|
||||
? this.$t("main.chargingPlan.preconditionOptionAll")
|
||||
: this.fmtDurationLong(s),
|
||||
}));
|
||||
},
|
||||
valueFmt() {
|
||||
return this.options.find((o: { value: number }) => o.value === this.localValue)?.name;
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
modelValue(newValue) {
|
||||
this.localValue = newValue;
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
toggle() {
|
||||
const DEFAULT_PRECONDITION = 3600; // 1 hour
|
||||
const newValue = this.localValue > 0 ? 0 : DEFAULT_PRECONDITION;
|
||||
this.localValue = newValue;
|
||||
this.$emit("update:modelValue", newValue);
|
||||
},
|
||||
onChange(event: Event) {
|
||||
const value = parseInt((event.target as HTMLSelectElement).value);
|
||||
this.$emit("update:modelValue", value);
|
||||
},
|
||||
},
|
||||
});
|
||||
</script>
|
||||
<style scoped>
|
||||
.form-check-input {
|
||||
margin-left: 0.1rem;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -6,6 +6,7 @@ export interface RepeatingPlan {
|
|||
tz: string; // timezone like "Europe/Berlin"
|
||||
soc: number;
|
||||
active: boolean;
|
||||
precondition: number;
|
||||
}
|
||||
|
||||
export interface PlanWrapper {
|
||||
|
|
@ -26,9 +27,11 @@ export type StaticPlan = StaticSocPlan | StaticEnergyPlan;
|
|||
export interface StaticSocPlan {
|
||||
soc: number;
|
||||
time: Date;
|
||||
precondition: number;
|
||||
}
|
||||
|
||||
export interface StaticEnergyPlan {
|
||||
energy: number;
|
||||
time: Date;
|
||||
precondition: number;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -165,6 +165,7 @@ export default {
|
|||
planProjectedEnd: String,
|
||||
planOverrun: Number,
|
||||
planEnergy: Number,
|
||||
planPrecondition: Number,
|
||||
planTime: String,
|
||||
effectivePlanTime: String,
|
||||
effectivePlanSoc: Number,
|
||||
|
|
@ -204,6 +205,7 @@ export default {
|
|||
multipleLoadpoints: Boolean,
|
||||
gridConfigured: Boolean,
|
||||
pvConfigured: Boolean,
|
||||
forecast: Object, // as PropType<Forecast>,
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
|
|
@ -281,6 +283,9 @@ export default {
|
|||
batteryBoostActive() {
|
||||
return this.batteryBoost && this.charging && !["off", "now"].includes(this.mode);
|
||||
},
|
||||
plannerForecast() {
|
||||
return this.forecast?.planner;
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
phaseRemaining() {
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@
|
|||
:gridConfigured="gridConfigured"
|
||||
:pvConfigured="pvConfigured"
|
||||
:batteryConfigured="batteryConfigured"
|
||||
:forecast="forecast"
|
||||
class="h-100"
|
||||
:class="{ 'loadpoint-unselected': !selected(index) }"
|
||||
@click="goTo(index)"
|
||||
|
|
@ -74,6 +75,7 @@ export default {
|
|||
gridConfigured: Boolean,
|
||||
pvConfigured: Boolean,
|
||||
batteryConfigured: Boolean,
|
||||
forecast: Object, // as PropType<Forecast>,
|
||||
},
|
||||
emits: ["index-changed"],
|
||||
data() {
|
||||
|
|
|
|||
|
|
@ -54,6 +54,7 @@
|
|||
:pvConfigured="pvConfigured"
|
||||
:batteryConfigured="batteryConfigured"
|
||||
:batterySoc="batterySoc"
|
||||
:forecast="forecast"
|
||||
:selectedIndex="selectedLoadpointIndex"
|
||||
@index-changed="selectedLoadpointChanged"
|
||||
/>
|
||||
|
|
@ -131,7 +132,7 @@ export default {
|
|||
sponsor: { type: Object, default: () => ({}) },
|
||||
smartCostType: String,
|
||||
fatal: Object,
|
||||
forecast: Object,
|
||||
forecast: Object, // as PropType<Forecast>,
|
||||
},
|
||||
computed: {
|
||||
batteryConfigured() {
|
||||
|
|
|
|||
|
|
@ -111,12 +111,14 @@ export default {
|
|||
chargerStatusReason: String,
|
||||
phaseAction: String,
|
||||
phaseRemainingInterpolated: Number,
|
||||
forecast: Object, // as PropType<Forecast>,
|
||||
planActive: Boolean,
|
||||
planEnergy: Number,
|
||||
planProjectedStart: String,
|
||||
planProjectedEnd: String,
|
||||
planTime: String,
|
||||
planTimeUnreachable: Boolean,
|
||||
planPrecondition: Number,
|
||||
planOverrun: Number,
|
||||
pvAction: String,
|
||||
pvRemainingInterpolated: Number,
|
||||
|
|
|
|||
|
|
@ -140,6 +140,19 @@ export default defineComponent({
|
|||
}
|
||||
return result;
|
||||
},
|
||||
fmtDurationLong(seconds: number) {
|
||||
// @ts-expect-error - Intl.DurationFormat is a new API not yet in TS types, see https://github.com/microsoft/TypeScript/issues/60608
|
||||
if (!Intl.DurationFormat) {
|
||||
// old browser fallback
|
||||
return this.fmtDuration(seconds);
|
||||
}
|
||||
const hours = Math.floor(seconds / 3600);
|
||||
const minutes = Math.floor((seconds % 3600) / 60);
|
||||
|
||||
// @ts-expect-error - Intl.DurationFormat is a new API not yet in TS types, see https://github.com/microsoft/TypeScript/issues/60608
|
||||
const formatter = new Intl.DurationFormat(this.$i18n?.locale, { style: "long" });
|
||||
return formatter.format({ minutes, hours });
|
||||
},
|
||||
fmtDayString(date: Date) {
|
||||
const YY = `${date.getFullYear()}`;
|
||||
const MM = `${date.getMonth() + 1}`.padStart(2, "0");
|
||||
|
|
|
|||
|
|
@ -46,13 +46,6 @@ export interface Tariff {
|
|||
lastUpdate: Date;
|
||||
}
|
||||
|
||||
// data from api with string-based timestamps
|
||||
export interface RateRaw {
|
||||
start: string;
|
||||
end: string;
|
||||
value: number;
|
||||
}
|
||||
|
||||
export interface Rate {
|
||||
start: Date;
|
||||
end: Date;
|
||||
|
|
|
|||
|
|
@ -1,14 +1,15 @@
|
|||
import type { RateRaw, Rate } from "../types/evcc";
|
||||
import type { Rate } from "../types/evcc";
|
||||
import type { ForecastSlot } from "./forecast";
|
||||
|
||||
function convertRate(rate: RateRaw): Rate {
|
||||
function convertRate(slot: ForecastSlot): Rate {
|
||||
return {
|
||||
start: new Date(rate.start),
|
||||
end: new Date(rate.end),
|
||||
value: rate.value,
|
||||
start: new Date(slot.start),
|
||||
end: new Date(slot.end),
|
||||
value: slot.value,
|
||||
};
|
||||
}
|
||||
|
||||
export default function convertRates(rates: RateRaw[] | null): Rate[] {
|
||||
if (!rates) return [];
|
||||
return rates.map(convertRate);
|
||||
export default function convertRates(slots: ForecastSlot[] | null): Rate[] {
|
||||
if (!slots) return [];
|
||||
return slots.map(convertRate);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -31,6 +31,7 @@ export interface SolarDetails {
|
|||
export interface Forecast {
|
||||
grid?: ForecastSlot[];
|
||||
co2?: ForecastSlot[];
|
||||
planner?: ForecastSlot[];
|
||||
solar?: SolarDetails;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -70,6 +70,7 @@ const (
|
|||
PlanTime = "planTime" // charge plan finish time goal
|
||||
PlanEnergy = "planEnergy" // charge plan energy goal
|
||||
PlanSoc = "planSoc" // charge plan soc goal
|
||||
PlanPrecondition = "planPrecondition" // charge plan precondition duration
|
||||
PlanActive = "planActive" // charge plan has determined current slot to be an active slot
|
||||
PlanProjectedStart = "planProjectedStart" // charge plan start time (earliest slot)
|
||||
PlanProjectedEnd = "planProjectedEnd" // charge plan ends (end of last slot)
|
||||
|
|
|
|||
|
|
@ -140,11 +140,12 @@ type Loadpoint struct {
|
|||
socEstimator *soc.Estimator
|
||||
|
||||
// charge planning
|
||||
planner *planner.Planner
|
||||
planTime time.Time // time goal
|
||||
planEnergy float64 // Plan charge energy in kWh (dumb vehicles)
|
||||
planSlotEnd time.Time // current plan slot end time
|
||||
planActive bool // charge plan exists and has a currently active slot
|
||||
planner *planner.Planner
|
||||
planTime time.Time // time goal
|
||||
planPrecondition time.Duration // precondition duration
|
||||
planEnergy float64 // Plan charge energy in kWh (dumb vehicles)
|
||||
planSlotEnd time.Time // current plan slot end time
|
||||
planActive bool // charge plan exists and has a currently active slot
|
||||
|
||||
// cached state
|
||||
status api.ChargeStatus // Charger status
|
||||
|
|
@ -342,8 +343,9 @@ func (lp *Loadpoint) restoreSettings() {
|
|||
|
||||
t, err1 := lp.settings.Time(keys.PlanTime)
|
||||
v, err2 := lp.settings.Float(keys.PlanEnergy)
|
||||
d, _ := lp.settings.Int(keys.PlanPrecondition)
|
||||
if err1 == nil && err2 == nil {
|
||||
lp.setPlanEnergy(t, v)
|
||||
lp.setPlanEnergy(t, time.Duration(d)*time.Second, v)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -657,6 +659,7 @@ func (lp *Loadpoint) Prepare(uiChan chan<- util.Param, pushChan chan<- push.Even
|
|||
// restored settings
|
||||
lp.publish(keys.PlanTime, lp.planTime)
|
||||
lp.publish(keys.PlanEnergy, lp.planEnergy)
|
||||
lp.publish(keys.PlanPrecondition, lp.planPrecondition)
|
||||
lp.publish(keys.LimitSoc, lp.limitSoc)
|
||||
lp.publish(keys.LimitEnergy, lp.limitEnergy)
|
||||
|
||||
|
|
@ -953,7 +956,7 @@ func (lp *Loadpoint) repeatingPlanning() bool {
|
|||
if !lp.socBasedPlanning() {
|
||||
return false
|
||||
}
|
||||
_, _, id := lp.NextVehiclePlan()
|
||||
_, _, _, id := lp.NextVehiclePlan()
|
||||
return id > 1
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -114,17 +114,19 @@ type API interface {
|
|||
//
|
||||
|
||||
// GetPlanEnergy returns the charge plan energy
|
||||
GetPlanEnergy() (time.Time, float64)
|
||||
GetPlanEnergy() (time.Time, time.Duration, float64)
|
||||
// SetPlanEnergy sets the charge plan energy
|
||||
SetPlanEnergy(time.Time, float64) error
|
||||
// GetPlanGoal returns the plan goal and if the goal is soc based
|
||||
SetPlanEnergy(time.Time, time.Duration, float64) error
|
||||
// GetPlanGoal returns the plan goal, precondition duration and if the goal is soc based
|
||||
GetPlanGoal() (float64, bool)
|
||||
// GetPlanRequiredDuration returns required duration of plan to reach the goal from current state
|
||||
GetPlanRequiredDuration(goal, maxPower float64) time.Duration
|
||||
// GetPlanPreCondDuration returns the precondition duration
|
||||
GetPlanPreCondDuration() time.Duration
|
||||
// SocBasedPlanning determines if the planner is soc based
|
||||
SocBasedPlanning() bool
|
||||
// GetPlan creates a charging plan
|
||||
GetPlan(targetTime time.Time, requiredDuration time.Duration) api.Rates
|
||||
GetPlan(targetTime time.Time, requiredDuration, precondition time.Duration) api.Rates
|
||||
|
||||
// GetSocConfig returns the soc poll settings
|
||||
GetSocConfig() SocConfig
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@ type DynamicConfig struct {
|
|||
SmartCostLimit *float64 `json:"smartCostLimit"`
|
||||
PlanEnergy float64 `json:"planEnergy"`
|
||||
PlanTime time.Time `json:"planTime"`
|
||||
PlanPrecondition int64 `json:"planPrecondition"`
|
||||
LimitEnergy float64 `json:"limitEnergy"`
|
||||
LimitSoc int `json:"limitSoc"`
|
||||
|
||||
|
|
@ -56,7 +57,7 @@ func (payload DynamicConfig) Apply(lp API) error {
|
|||
lp.SetPriority(payload.Priority)
|
||||
lp.SetSmartCostLimit(payload.SmartCostLimit)
|
||||
lp.SetThresholds(payload.Thresholds)
|
||||
lp.SetPlanEnergy(payload.PlanTime, payload.PlanEnergy)
|
||||
lp.SetPlanEnergy(payload.PlanTime, time.Duration(payload.PlanPrecondition)*time.Second, payload.PlanEnergy)
|
||||
lp.SetLimitEnergy(payload.LimitEnergy)
|
||||
lp.SetLimitSoc(payload.LimitSoc)
|
||||
|
||||
|
|
|
|||
|
|
@ -420,26 +420,27 @@ func (mr *MockAPIMockRecorder) GetPhasesConfigured() *gomock.Call {
|
|||
}
|
||||
|
||||
// GetPlan mocks base method.
|
||||
func (m *MockAPI) GetPlan(targetTime time.Time, requiredDuration time.Duration) api.Rates {
|
||||
func (m *MockAPI) GetPlan(targetTime time.Time, requiredDuration, precondition time.Duration) api.Rates {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "GetPlan", targetTime, requiredDuration)
|
||||
ret := m.ctrl.Call(m, "GetPlan", targetTime, requiredDuration, precondition)
|
||||
ret0, _ := ret[0].(api.Rates)
|
||||
return ret0
|
||||
}
|
||||
|
||||
// GetPlan indicates an expected call of GetPlan.
|
||||
func (mr *MockAPIMockRecorder) GetPlan(targetTime, requiredDuration any) *gomock.Call {
|
||||
func (mr *MockAPIMockRecorder) GetPlan(targetTime, requiredDuration, precondition any) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetPlan", reflect.TypeOf((*MockAPI)(nil).GetPlan), targetTime, requiredDuration)
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetPlan", reflect.TypeOf((*MockAPI)(nil).GetPlan), targetTime, requiredDuration, precondition)
|
||||
}
|
||||
|
||||
// GetPlanEnergy mocks base method.
|
||||
func (m *MockAPI) GetPlanEnergy() (time.Time, float64) {
|
||||
func (m *MockAPI) GetPlanEnergy() (time.Time, time.Duration, float64) {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "GetPlanEnergy")
|
||||
ret0, _ := ret[0].(time.Time)
|
||||
ret1, _ := ret[1].(float64)
|
||||
return ret0, ret1
|
||||
ret1, _ := ret[1].(time.Duration)
|
||||
ret2, _ := ret[2].(float64)
|
||||
return ret0, ret1, ret2
|
||||
}
|
||||
|
||||
// GetPlanEnergy indicates an expected call of GetPlanEnergy.
|
||||
|
|
@ -463,6 +464,20 @@ func (mr *MockAPIMockRecorder) GetPlanGoal() *gomock.Call {
|
|||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetPlanGoal", reflect.TypeOf((*MockAPI)(nil).GetPlanGoal))
|
||||
}
|
||||
|
||||
// GetPlanPreCondDuration mocks base method.
|
||||
func (m *MockAPI) GetPlanPreCondDuration() time.Duration {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "GetPlanPreCondDuration")
|
||||
ret0, _ := ret[0].(time.Duration)
|
||||
return ret0
|
||||
}
|
||||
|
||||
// GetPlanPreCondDuration indicates an expected call of GetPlanPreCondDuration.
|
||||
func (mr *MockAPIMockRecorder) GetPlanPreCondDuration() *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetPlanPreCondDuration", reflect.TypeOf((*MockAPI)(nil).GetPlanPreCondDuration))
|
||||
}
|
||||
|
||||
// GetPlanRequiredDuration mocks base method.
|
||||
func (m *MockAPI) GetPlanRequiredDuration(goal, maxPower float64) time.Duration {
|
||||
m.ctrl.T.Helper()
|
||||
|
|
@ -844,17 +859,17 @@ func (mr *MockAPIMockRecorder) SetPhasesConfigured(arg0 any) *gomock.Call {
|
|||
}
|
||||
|
||||
// SetPlanEnergy mocks base method.
|
||||
func (m *MockAPI) SetPlanEnergy(arg0 time.Time, arg1 float64) error {
|
||||
func (m *MockAPI) SetPlanEnergy(arg0 time.Time, arg1 time.Duration, arg2 float64) error {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "SetPlanEnergy", arg0, arg1)
|
||||
ret := m.ctrl.Call(m, "SetPlanEnergy", arg0, arg1, arg2)
|
||||
ret0, _ := ret[0].(error)
|
||||
return ret0
|
||||
}
|
||||
|
||||
// SetPlanEnergy indicates an expected call of SetPlanEnergy.
|
||||
func (mr *MockAPIMockRecorder) SetPlanEnergy(arg0, arg1 any) *gomock.Call {
|
||||
func (mr *MockAPIMockRecorder) SetPlanEnergy(arg0, arg1, arg2 any) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SetPlanEnergy", reflect.TypeOf((*MockAPI)(nil).SetPlanEnergy), arg0, arg1)
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SetPlanEnergy", reflect.TypeOf((*MockAPI)(nil).SetPlanEnergy), arg0, arg1, arg2)
|
||||
}
|
||||
|
||||
// SetPriority mocks base method.
|
||||
|
|
|
|||
|
|
@ -327,19 +327,19 @@ func (lp *Loadpoint) SetLimitEnergy(energy float64) {
|
|||
}
|
||||
|
||||
// GetPlanEnergy returns plan target energy
|
||||
func (lp *Loadpoint) GetPlanEnergy() (time.Time, float64) {
|
||||
func (lp *Loadpoint) GetPlanEnergy() (time.Time, time.Duration, float64) {
|
||||
lp.RLock()
|
||||
defer lp.RUnlock()
|
||||
return lp.getPlanEnergy()
|
||||
}
|
||||
|
||||
// getPlanEnergy returns plan target energy
|
||||
func (lp *Loadpoint) getPlanEnergy() (time.Time, float64) {
|
||||
return lp.planTime, lp.planEnergy
|
||||
func (lp *Loadpoint) getPlanEnergy() (time.Time, time.Duration, float64) {
|
||||
return lp.planTime, lp.planPrecondition, lp.planEnergy
|
||||
}
|
||||
|
||||
// setPlanEnergy sets plan target energy (no mutex)
|
||||
func (lp *Loadpoint) setPlanEnergy(finishAt time.Time, energy float64) {
|
||||
func (lp *Loadpoint) setPlanEnergy(finishAt time.Time, precondition time.Duration, energy float64) {
|
||||
lp.planEnergy = energy
|
||||
lp.publish(keys.PlanEnergy, energy)
|
||||
lp.settings.SetFloat(keys.PlanEnergy, energy)
|
||||
|
|
@ -347,11 +347,14 @@ func (lp *Loadpoint) setPlanEnergy(finishAt time.Time, energy float64) {
|
|||
// remove plan
|
||||
if energy == 0 {
|
||||
finishAt = time.Time{}
|
||||
precondition = 0
|
||||
}
|
||||
|
||||
lp.planTime = finishAt
|
||||
lp.publish(keys.PlanTime, finishAt)
|
||||
lp.publish(keys.PlanPrecondition, precondition)
|
||||
lp.settings.SetTime(keys.PlanTime, finishAt)
|
||||
lp.settings.SetInt(keys.PlanPrecondition, int64(precondition.Seconds()))
|
||||
|
||||
if finishAt.IsZero() {
|
||||
lp.setPlanActive(false)
|
||||
|
|
@ -359,7 +362,7 @@ func (lp *Loadpoint) setPlanEnergy(finishAt time.Time, energy float64) {
|
|||
}
|
||||
|
||||
// SetPlanEnergy sets plan target energy
|
||||
func (lp *Loadpoint) SetPlanEnergy(finishAt time.Time, energy float64) error {
|
||||
func (lp *Loadpoint) SetPlanEnergy(finishAt time.Time, precondition time.Duration, energy float64) error {
|
||||
lp.Lock()
|
||||
defer lp.Unlock()
|
||||
|
||||
|
|
@ -370,8 +373,8 @@ func (lp *Loadpoint) SetPlanEnergy(finishAt time.Time, energy float64) error {
|
|||
lp.log.DEBUG.Printf("set plan energy: %.3gkWh @ %v", energy, finishAt.Round(time.Second).Local())
|
||||
|
||||
// apply immediately
|
||||
if lp.planEnergy != energy || !lp.planTime.Equal(finishAt) {
|
||||
lp.setPlanEnergy(finishAt, energy)
|
||||
if lp.planEnergy != energy || lp.planPrecondition != precondition || !lp.planTime.Equal(finishAt) {
|
||||
lp.setPlanEnergy(finishAt, precondition, energy)
|
||||
lp.requestUpdate()
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -32,10 +32,11 @@ func (lp *Loadpoint) EffectivePriority() int {
|
|||
}
|
||||
|
||||
type plan struct {
|
||||
Id int
|
||||
Start time.Time // last possible start time
|
||||
End time.Time // user-selected finish time
|
||||
Soc int
|
||||
Id int
|
||||
Start time.Time // last possible start time
|
||||
End time.Time // user-selected finish time
|
||||
Precondition time.Duration
|
||||
Soc int
|
||||
}
|
||||
|
||||
func (lp *Loadpoint) nextActivePlan(maxPower float64, plans []plan) *plan {
|
||||
|
|
@ -59,20 +60,20 @@ func (lp *Loadpoint) nextActivePlan(maxPower float64, plans []plan) *plan {
|
|||
}
|
||||
|
||||
// NextVehiclePlan returns the next vehicle plan time, soc and id
|
||||
func (lp *Loadpoint) NextVehiclePlan() (time.Time, int, int) {
|
||||
func (lp *Loadpoint) NextVehiclePlan() (time.Time, time.Duration, int, int) {
|
||||
lp.RLock()
|
||||
defer lp.RUnlock()
|
||||
return lp.nextVehiclePlan()
|
||||
}
|
||||
|
||||
// nextVehiclePlan returns the next vehicle plan time, soc and id
|
||||
func (lp *Loadpoint) nextVehiclePlan() (time.Time, int, int) {
|
||||
// nextVehiclePlan returns the next vehicle plan time, precondition duration, soc and id
|
||||
func (lp *Loadpoint) nextVehiclePlan() (time.Time, time.Duration, int, int) {
|
||||
if v := lp.GetVehicle(); v != nil {
|
||||
var plans []plan
|
||||
|
||||
// static plan
|
||||
if planTime, soc := vehicle.Settings(lp.log, v).GetPlanSoc(); soc != 0 {
|
||||
plans = append(plans, plan{Id: 1, Soc: soc, End: planTime})
|
||||
if planTime, precondition, soc := vehicle.Settings(lp.log, v).GetPlanSoc(); soc != 0 {
|
||||
plans = append(plans, plan{Id: 1, Precondition: precondition, Soc: soc, End: planTime})
|
||||
}
|
||||
|
||||
// repeating plans
|
||||
|
|
@ -81,33 +82,34 @@ func (lp *Loadpoint) nextVehiclePlan() (time.Time, int, int) {
|
|||
continue
|
||||
}
|
||||
|
||||
time, err := util.GetNextOccurrence(rp.Weekdays, rp.Time, rp.Tz)
|
||||
planTime, err := util.GetNextOccurrence(rp.Weekdays, rp.Time, rp.Tz)
|
||||
if err != nil {
|
||||
lp.log.DEBUG.Printf("invalid repeating plan: weekdays=%v, time=%s, tz=%s, error=%v", rp.Weekdays, rp.Time, rp.Tz, err)
|
||||
continue
|
||||
}
|
||||
|
||||
plans = append(plans, plan{Id: index + 2, Soc: rp.Soc, End: time})
|
||||
precondition := time.Duration(rp.Precondition) * time.Second
|
||||
plans = append(plans, plan{Id: index + 2, Precondition: precondition, Soc: rp.Soc, End: planTime})
|
||||
}
|
||||
|
||||
// calculate earliest required plan start
|
||||
if plan := lp.nextActivePlan(lp.effectiveMaxPower(), plans); plan != nil {
|
||||
return plan.End, plan.Soc, plan.Id
|
||||
return plan.End, plan.Precondition, plan.Soc, plan.Id
|
||||
}
|
||||
}
|
||||
return time.Time{}, 0, 0
|
||||
return time.Time{}, 0, 0, 0
|
||||
}
|
||||
|
||||
// EffectivePlanSoc returns the soc target for the current plan
|
||||
func (lp *Loadpoint) EffectivePlanSoc() int {
|
||||
_, soc, _ := lp.NextVehiclePlan()
|
||||
_, _, soc, _ := lp.NextVehiclePlan()
|
||||
return soc
|
||||
}
|
||||
|
||||
// EffectivePlanId returns the id for the current plan
|
||||
func (lp *Loadpoint) EffectivePlanId() int {
|
||||
if lp.socBasedPlanning() {
|
||||
_, _, id := lp.NextVehiclePlan()
|
||||
_, _, _, id := lp.NextVehiclePlan()
|
||||
return id
|
||||
}
|
||||
if lp.planEnergy > 0 {
|
||||
|
|
@ -120,11 +122,11 @@ func (lp *Loadpoint) EffectivePlanId() int {
|
|||
// EffectivePlanTime returns the effective plan time
|
||||
func (lp *Loadpoint) EffectivePlanTime() time.Time {
|
||||
if lp.socBasedPlanning() {
|
||||
ts, _, _ := lp.NextVehiclePlan()
|
||||
ts, _, _, _ := lp.NextVehiclePlan()
|
||||
return ts
|
||||
}
|
||||
|
||||
ts, _ := lp.GetPlanEnergy()
|
||||
ts, _, _ := lp.GetPlanEnergy()
|
||||
return ts
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -33,9 +33,9 @@ func (lp *Loadpoint) finishPlan() {
|
|||
if lp.repeatingPlanning() {
|
||||
return // noting to do
|
||||
} else if !lp.socBasedPlanning() {
|
||||
lp.setPlanEnergy(time.Time{}, 0)
|
||||
lp.setPlanEnergy(time.Time{}, 0, 0)
|
||||
} else if v := lp.GetVehicle(); v != nil {
|
||||
vehicle.Settings(lp.log, v).SetPlanSoc(time.Time{}, 0)
|
||||
vehicle.Settings(lp.log, v).SetPlanSoc(time.Time{}, 0, 0)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -70,21 +70,36 @@ func (lp *Loadpoint) GetPlanGoal() (float64, bool) {
|
|||
defer lp.RUnlock()
|
||||
|
||||
if lp.socBasedPlanning() {
|
||||
_, soc, _ := lp.nextVehiclePlan()
|
||||
_, _, soc, _ := lp.nextVehiclePlan()
|
||||
return float64(soc), true
|
||||
}
|
||||
|
||||
_, limit := lp.getPlanEnergy()
|
||||
_, _, limit := lp.getPlanEnergy()
|
||||
return limit, false
|
||||
}
|
||||
|
||||
// GetPlanPreCondDuration returns the plan precondition duration
|
||||
func (lp *Loadpoint) GetPlanPreCondDuration() time.Duration {
|
||||
lp.RLock()
|
||||
defer lp.RUnlock()
|
||||
|
||||
if lp.socBasedPlanning() {
|
||||
_, precondition, _, _ := lp.nextVehiclePlan()
|
||||
return precondition
|
||||
}
|
||||
|
||||
_, precondition, _ := lp.getPlanEnergy()
|
||||
return precondition
|
||||
}
|
||||
|
||||
// GetPlan creates a charging plan for given time and duration
|
||||
func (lp *Loadpoint) GetPlan(targetTime time.Time, requiredDuration time.Duration) api.Rates {
|
||||
// The plan is sorted by time
|
||||
func (lp *Loadpoint) GetPlan(targetTime time.Time, requiredDuration, precondition time.Duration) api.Rates {
|
||||
if lp.planner == nil || targetTime.IsZero() {
|
||||
return nil
|
||||
}
|
||||
|
||||
return lp.planner.Plan(requiredDuration, targetTime)
|
||||
return lp.planner.Plan(requiredDuration, precondition, targetTime)
|
||||
}
|
||||
|
||||
// plannerActive checks if the charging plan has a currently active slot
|
||||
|
|
@ -132,7 +147,7 @@ func (lp *Loadpoint) plannerActive() (active bool) {
|
|||
return false
|
||||
}
|
||||
|
||||
plan := lp.GetPlan(planTime, requiredDuration)
|
||||
plan := lp.GetPlan(planTime, requiredDuration, lp.GetPlanPreCondDuration())
|
||||
if plan == nil {
|
||||
return false
|
||||
}
|
||||
|
|
|
|||
|
|
@ -136,7 +136,7 @@ func (t *Planner) continuousPlan(rates api.Rates, start, end time.Time) api.Rate
|
|||
return res
|
||||
}
|
||||
|
||||
func (t *Planner) Plan(requiredDuration time.Duration, targetTime time.Time) api.Rates {
|
||||
func (t *Planner) Plan(requiredDuration, precondition time.Duration, targetTime time.Time) api.Rates {
|
||||
if t == nil || requiredDuration <= 0 {
|
||||
return nil
|
||||
}
|
||||
|
|
@ -178,6 +178,9 @@ func (t *Planner) Plan(requiredDuration time.Duration, targetTime time.Time) api
|
|||
// sort rates by price and time
|
||||
slices.SortStableFunc(rates, sortByCost)
|
||||
|
||||
// for late start ensure that the last slot is the cheapest
|
||||
rates, adjusted := splitPreconditionSlots(rates, precondition, targetTime)
|
||||
|
||||
// reduce planning horizon to available rates
|
||||
if targetTime.After(last) {
|
||||
// there is enough time for charging after end of current rates
|
||||
|
|
@ -194,10 +197,62 @@ func (t *Planner) Plan(requiredDuration time.Duration, targetTime time.Time) api
|
|||
requiredDuration -= durationAfterRates
|
||||
}
|
||||
|
||||
// sort rates by price and time
|
||||
slices.SortStableFunc(rates, sortByCost)
|
||||
|
||||
plan := t.plan(rates, requiredDuration, targetTime)
|
||||
|
||||
// correct plan slots to show original, non-adjusted prices
|
||||
for i, r := range plan {
|
||||
if rr, err := adjusted.At(r.Start); err == nil {
|
||||
plan[i].Value = rr.Value
|
||||
}
|
||||
}
|
||||
|
||||
// sort plan by time
|
||||
plan.Sort()
|
||||
|
||||
return plan
|
||||
}
|
||||
|
||||
func splitPreconditionSlots(rates api.Rates, precondition time.Duration, targetTime time.Time) (api.Rates, api.Rates) {
|
||||
var res, adjusted api.Rates
|
||||
|
||||
for _, r := range slices.Clone(rates) {
|
||||
preCondStart := targetTime.Add(-precondition)
|
||||
|
||||
if !r.End.After(preCondStart) {
|
||||
res = append(res, r)
|
||||
continue
|
||||
}
|
||||
|
||||
// split slot
|
||||
if !r.Start.After(preCondStart) {
|
||||
// keep the first part of the slot
|
||||
res = append(res, api.Rate{
|
||||
Start: r.Start,
|
||||
End: preCondStart,
|
||||
Value: r.Value,
|
||||
})
|
||||
|
||||
// adjust the second part of the slot
|
||||
r = api.Rate{
|
||||
Start: preCondStart,
|
||||
End: r.End,
|
||||
Value: r.Value,
|
||||
}
|
||||
}
|
||||
|
||||
// set the value to 0 to include slot in the plan
|
||||
res = append(res, api.Rate{
|
||||
Start: r.Start,
|
||||
End: r.End,
|
||||
Value: 0,
|
||||
})
|
||||
|
||||
// keep a copy of the adjusted slot
|
||||
adjusted = append(adjusted, r)
|
||||
}
|
||||
|
||||
return res, adjusted
|
||||
}
|
||||
|
|
|
|||
|
|
@ -133,7 +133,7 @@ func TestNilTariff(t *testing.T) {
|
|||
clock: clock,
|
||||
}
|
||||
|
||||
plan := p.Plan(time.Hour, clock.Now().Add(30*time.Minute))
|
||||
plan := p.Plan(time.Hour, 0, clock.Now().Add(30*time.Minute))
|
||||
assert.Equal(t, api.Rates{
|
||||
{
|
||||
Start: clock.Now(),
|
||||
|
|
@ -155,7 +155,7 @@ func TestRatesError(t *testing.T) {
|
|||
tariff: trf,
|
||||
}
|
||||
|
||||
plan := p.Plan(time.Hour, clock.Now().Add(30*time.Minute))
|
||||
plan := p.Plan(time.Hour, 0, clock.Now().Add(30*time.Minute))
|
||||
assert.Equal(t, api.Rates{
|
||||
{
|
||||
Start: clock.Now(),
|
||||
|
|
@ -184,10 +184,10 @@ func TestFlatTariffTargetInThePast(t *testing.T) {
|
|||
},
|
||||
}
|
||||
|
||||
plan := p.Plan(time.Hour, clock.Now().Add(30*time.Minute))
|
||||
plan := p.Plan(time.Hour, 0, clock.Now().Add(30*time.Minute))
|
||||
assert.Equal(t, simplePlan, plan, "expected simple plan")
|
||||
|
||||
plan = p.Plan(time.Hour, clock.Now().Add(-30*time.Minute))
|
||||
plan = p.Plan(time.Hour, 0, clock.Now().Add(-30*time.Minute))
|
||||
assert.Equal(t, simplePlan, plan, "expected simple plan")
|
||||
}
|
||||
|
||||
|
|
@ -208,12 +208,12 @@ func TestFlatTariffLongSlots(t *testing.T) {
|
|||
// that slots are not longer than 1 hour and with that context this is not a problem
|
||||
|
||||
// expect 00:00-01:00 UTC
|
||||
plan := p.Plan(time.Hour, clock.Now().Add(2*time.Hour))
|
||||
plan := p.Plan(time.Hour, 0, clock.Now().Add(2*time.Hour))
|
||||
assert.Equal(t, api.Rate{Start: clock.Now(), End: clock.Now().Add(time.Hour)}, SlotAt(clock.Now(), plan))
|
||||
assert.Equal(t, api.Rate{}, SlotAt(clock.Now().Add(time.Hour), plan))
|
||||
|
||||
// expect 00:00-01:00 UTC
|
||||
plan = p.Plan(time.Hour, clock.Now().Add(time.Hour))
|
||||
plan = p.Plan(time.Hour, 0, clock.Now().Add(time.Hour))
|
||||
assert.Equal(t, api.Rate{Start: clock.Now(), End: clock.Now().Add(time.Hour)}, SlotAt(clock.Now(), plan))
|
||||
}
|
||||
|
||||
|
|
@ -230,10 +230,10 @@ func TestTargetAfterKnownPrices(t *testing.T) {
|
|||
tariff: trf,
|
||||
}
|
||||
|
||||
plan := p.Plan(40*time.Minute, clock.Now().Add(2*time.Hour)) // charge efficiency does not allow to test with 1h
|
||||
plan := p.Plan(40*time.Minute, 0, clock.Now().Add(2*time.Hour)) // charge efficiency does not allow to test with 1h
|
||||
assert.False(t, !SlotAt(clock.Now(), plan).IsZero(), "should not start if car can be charged completely after known prices ")
|
||||
|
||||
plan = p.Plan(2*time.Hour, clock.Now().Add(2*time.Hour))
|
||||
plan = p.Plan(2*time.Hour, 0, clock.Now().Add(2*time.Hour))
|
||||
assert.True(t, !SlotAt(clock.Now(), plan).IsZero(), "should start if car can not be charged completely after known prices ")
|
||||
}
|
||||
|
||||
|
|
@ -257,13 +257,64 @@ func TestChargeAfterTargetTime(t *testing.T) {
|
|||
},
|
||||
}
|
||||
|
||||
plan := p.Plan(time.Hour, clock.Now())
|
||||
plan := p.Plan(time.Hour, 0, clock.Now())
|
||||
assert.Equal(t, simplePlan, plan, "expected simple plan")
|
||||
|
||||
plan = p.Plan(time.Hour, clock.Now().Add(-time.Hour))
|
||||
plan = p.Plan(time.Hour, 0, clock.Now().Add(-time.Hour))
|
||||
assert.Equal(t, simplePlan, plan, "expected simple plan")
|
||||
}
|
||||
|
||||
func TestPrecondition(t *testing.T) {
|
||||
clock := clock.NewMock()
|
||||
ctrl := gomock.NewController(t)
|
||||
|
||||
trf := api.NewMockTariff(ctrl)
|
||||
trf.EXPECT().Rates().AnyTimes().Return(rates([]float64{0, 1, 2, 3}, clock.Now(), time.Hour), nil)
|
||||
|
||||
p := &Planner{
|
||||
log: util.NewLogger("foo"),
|
||||
clock: clock,
|
||||
tariff: trf,
|
||||
}
|
||||
|
||||
plan := p.Plan(time.Hour, time.Hour, clock.Now().Add(4*time.Hour))
|
||||
assert.Equal(t, api.Rates{
|
||||
{
|
||||
Start: clock.Now().Add(3 * time.Hour),
|
||||
End: clock.Now().Add(4 * time.Hour),
|
||||
Value: 3,
|
||||
},
|
||||
}, plan, "expected last slot")
|
||||
|
||||
plan = p.Plan(2*time.Hour, time.Hour, clock.Now().Add(4*time.Hour))
|
||||
assert.Equal(t, api.Rates{
|
||||
{
|
||||
Start: clock.Now(),
|
||||
End: clock.Now().Add(1 * time.Hour),
|
||||
Value: 0,
|
||||
},
|
||||
{
|
||||
Start: clock.Now().Add(3 * time.Hour),
|
||||
End: clock.Now().Add(4 * time.Hour),
|
||||
Value: 3,
|
||||
},
|
||||
}, plan, "expected two slots")
|
||||
|
||||
plan = p.Plan(time.Hour, 30*time.Minute, clock.Now().Add(4*time.Hour))
|
||||
assert.Equal(t, api.Rates{
|
||||
{
|
||||
Start: clock.Now().Add(30 * time.Minute),
|
||||
End: clock.Now().Add(time.Hour),
|
||||
Value: 0,
|
||||
},
|
||||
{
|
||||
Start: clock.Now().Add(210 * time.Minute),
|
||||
End: clock.Now().Add(4 * time.Hour),
|
||||
Value: 3,
|
||||
},
|
||||
}, plan, "expected short early and split late slot")
|
||||
}
|
||||
|
||||
func TestContinuousPlanNoTariff(t *testing.T) {
|
||||
clock := clock.NewMock()
|
||||
|
||||
|
|
@ -272,7 +323,7 @@ func TestContinuousPlanNoTariff(t *testing.T) {
|
|||
clock: clock,
|
||||
}
|
||||
|
||||
plan := p.Plan(time.Hour, clock.Now())
|
||||
plan := p.Plan(time.Hour, 0, clock.Now())
|
||||
|
||||
// single-slot plan
|
||||
assert.Len(t, plan, 1)
|
||||
|
|
@ -293,7 +344,7 @@ func TestContinuousPlan(t *testing.T) {
|
|||
tariff: trf,
|
||||
}
|
||||
|
||||
plan := p.Plan(150*time.Minute, clock.Now())
|
||||
plan := p.Plan(150*time.Minute, 0, clock.Now())
|
||||
|
||||
// 3-slot plan
|
||||
assert.Len(t, plan, 3)
|
||||
|
|
@ -312,7 +363,7 @@ func TestContinuousPlanOutsideRates(t *testing.T) {
|
|||
tariff: trf,
|
||||
}
|
||||
|
||||
plan := p.Plan(30*time.Minute, clock.Now())
|
||||
plan := p.Plan(30*time.Minute, 0, clock.Now())
|
||||
|
||||
// 3-slot plan
|
||||
assert.Len(t, plan, 1)
|
||||
|
|
|
|||
|
|
@ -99,14 +99,16 @@ func (site *Site) publishTariffs(greenShareHome float64, greenShareLoadpoints fl
|
|||
}
|
||||
|
||||
fc := struct {
|
||||
Co2 api.Rates `json:"co2,omitempty"`
|
||||
FeedIn api.Rates `json:"feedin,omitempty"`
|
||||
Grid api.Rates `json:"grid,omitempty"`
|
||||
Solar *solarDetails `json:"solar,omitempty"`
|
||||
Co2 api.Rates `json:"co2,omitempty"`
|
||||
FeedIn api.Rates `json:"feedin,omitempty"`
|
||||
Grid api.Rates `json:"grid,omitempty"`
|
||||
Planner api.Rates `json:"planner,omitempty"`
|
||||
Solar *solarDetails `json:"solar,omitempty"`
|
||||
}{
|
||||
Co2: tariff.Forecast(site.GetTariff(api.TariffUsageCo2)),
|
||||
FeedIn: tariff.Forecast(site.GetTariff(api.TariffUsageFeedIn)),
|
||||
Grid: tariff.Forecast(site.GetTariff(api.TariffUsageGrid)),
|
||||
Co2: tariff.Forecast(site.GetTariff(api.TariffUsageCo2)),
|
||||
FeedIn: tariff.Forecast(site.GetTariff(api.TariffUsageFeedIn)),
|
||||
Planner: tariff.Forecast(site.GetTariff(api.TariffUsagePlanner)),
|
||||
Grid: tariff.Forecast(site.GetTariff(api.TariffUsageGrid)),
|
||||
}
|
||||
|
||||
// calculate adjusted solar forecast
|
||||
|
|
|
|||
|
|
@ -13,8 +13,9 @@ import (
|
|||
)
|
||||
|
||||
type planStruct struct {
|
||||
Soc int `json:"soc"`
|
||||
Time time.Time `json:"time"`
|
||||
Soc int `json:"soc"`
|
||||
Precondition int64 `json:"precondition"`
|
||||
Time time.Time `json:"time"`
|
||||
}
|
||||
|
||||
type vehicleStruct struct {
|
||||
|
|
@ -40,8 +41,8 @@ func (site *Site) publishVehicles() {
|
|||
for _, v := range vv {
|
||||
var plan *planStruct
|
||||
|
||||
if time, soc := v.GetPlanSoc(); !time.IsZero() {
|
||||
plan = &planStruct{Soc: soc, Time: time}
|
||||
if time, precondition, soc := v.GetPlanSoc(); !time.IsZero() {
|
||||
plan = &planStruct{Soc: soc, Precondition: int64(precondition.Seconds()), Time: time}
|
||||
}
|
||||
|
||||
instance := v.Instance()
|
||||
|
|
|
|||
|
|
@ -71,20 +71,24 @@ func (v *adapter) SetLimitSoc(soc int) {
|
|||
}
|
||||
|
||||
// GetPlanSoc returns the charge plan soc
|
||||
func (v *adapter) GetPlanSoc() (time.Time, int) {
|
||||
func (v *adapter) GetPlanSoc() (time.Time, time.Duration, int) {
|
||||
var ts time.Time
|
||||
if v, err := settings.Time(v.key() + keys.PlanTime); err == nil {
|
||||
ts = v
|
||||
}
|
||||
var precondition time.Duration
|
||||
if v, err := settings.Int(v.key() + keys.PlanPrecondition); err == nil {
|
||||
precondition = time.Duration(v) * time.Second
|
||||
}
|
||||
var soc int
|
||||
if v, err := settings.Int(v.key() + keys.PlanSoc); err == nil {
|
||||
soc = int(v)
|
||||
}
|
||||
return ts, soc
|
||||
return ts, precondition, soc
|
||||
}
|
||||
|
||||
// SetPlanSoc sets the charge plan soc
|
||||
func (v *adapter) SetPlanSoc(ts time.Time, soc int) error {
|
||||
func (v *adapter) SetPlanSoc(ts time.Time, precondition time.Duration, soc int) error {
|
||||
if !ts.IsZero() && ts.Before(time.Now()) {
|
||||
return errors.New("timestamp is in the past")
|
||||
}
|
||||
|
|
@ -94,10 +98,11 @@ func (v *adapter) SetPlanSoc(ts time.Time, soc int) error {
|
|||
ts = time.Time{}
|
||||
v.log.DEBUG.Printf("delete %s plan", v.name)
|
||||
} else {
|
||||
v.log.DEBUG.Printf("set %s plan soc: %d @ %v", v.name, soc, ts.Round(time.Second).Local())
|
||||
v.log.DEBUG.Printf("set %s plan soc: %d @ %v (precondition: %v)", v.name, soc, ts.Round(time.Second).Local(), precondition)
|
||||
}
|
||||
|
||||
settings.SetTime(v.key()+keys.PlanTime, ts)
|
||||
settings.SetInt(v.key()+keys.PlanPrecondition, int64(precondition.Seconds()))
|
||||
settings.SetInt(v.key()+keys.PlanSoc, int64(soc))
|
||||
|
||||
v.publish()
|
||||
|
|
|
|||
|
|
@ -39,9 +39,9 @@ type API interface {
|
|||
SetLimitSoc(soc int)
|
||||
|
||||
// GetPlanSoc returns the charge plan soc
|
||||
GetPlanSoc() (time.Time, int)
|
||||
GetPlanSoc() (time.Time, time.Duration, int)
|
||||
// SetPlanSoc sets the charge plan time and soc
|
||||
SetPlanSoc(time.Time, int) error
|
||||
SetPlanSoc(time.Time, time.Duration, int) error
|
||||
|
||||
// GetRepeatingPlans returns every repeating plan
|
||||
GetRepeatingPlans() []api.RepeatingPlanStruct
|
||||
|
|
|
|||
|
|
@ -39,12 +39,12 @@ func (v *dummy) SetLimitSoc(soc int) {
|
|||
}
|
||||
|
||||
// GetPlanSoc returns the charge plan soc
|
||||
func (v *dummy) GetPlanSoc() (time.Time, int) {
|
||||
return time.Time{}, 0
|
||||
func (v *dummy) GetPlanSoc() (time.Time, time.Duration, int) {
|
||||
return time.Time{}, 0, 0
|
||||
}
|
||||
|
||||
// SetPlanSoc sets the charge plan soc
|
||||
func (v *dummy) SetPlanSoc(ts time.Time, soc int) error {
|
||||
func (v *dummy) SetPlanSoc(ts time.Time, precondition time.Duration, soc int) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -70,12 +70,13 @@ func (mr *MockAPIMockRecorder) GetMinSoc() *gomock.Call {
|
|||
}
|
||||
|
||||
// GetPlanSoc mocks base method.
|
||||
func (m *MockAPI) GetPlanSoc() (time.Time, int) {
|
||||
func (m *MockAPI) GetPlanSoc() (time.Time, time.Duration, int) {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "GetPlanSoc")
|
||||
ret0, _ := ret[0].(time.Time)
|
||||
ret1, _ := ret[1].(int)
|
||||
return ret0, ret1
|
||||
ret1, _ := ret[1].(time.Duration)
|
||||
ret2, _ := ret[2].(int)
|
||||
return ret0, ret1, ret2
|
||||
}
|
||||
|
||||
// GetPlanSoc indicates an expected call of GetPlanSoc.
|
||||
|
|
@ -151,17 +152,17 @@ func (mr *MockAPIMockRecorder) SetMinSoc(soc any) *gomock.Call {
|
|||
}
|
||||
|
||||
// SetPlanSoc mocks base method.
|
||||
func (m *MockAPI) SetPlanSoc(arg0 time.Time, arg1 int) error {
|
||||
func (m *MockAPI) SetPlanSoc(arg0 time.Time, arg1 time.Duration, arg2 int) error {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "SetPlanSoc", arg0, arg1)
|
||||
ret := m.ctrl.Call(m, "SetPlanSoc", arg0, arg1, arg2)
|
||||
ret0, _ := ret[0].(error)
|
||||
return ret0
|
||||
}
|
||||
|
||||
// SetPlanSoc indicates an expected call of SetPlanSoc.
|
||||
func (mr *MockAPIMockRecorder) SetPlanSoc(arg0, arg1 any) *gomock.Call {
|
||||
func (mr *MockAPIMockRecorder) SetPlanSoc(arg0, arg1, arg2 any) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SetPlanSoc", reflect.TypeOf((*MockAPI)(nil).SetPlanSoc), arg0, arg1)
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SetPlanSoc", reflect.TypeOf((*MockAPI)(nil).SetPlanSoc), arg0, arg1, arg2)
|
||||
}
|
||||
|
||||
// SetRepeatingPlans mocks base method.
|
||||
|
|
|
|||
|
|
@ -592,6 +592,11 @@ goal = "Ladeziel"
|
|||
modalTitle = "Ladeplanung"
|
||||
none = "keiner"
|
||||
planNumber = "Plan {number}"
|
||||
preconditionDescription = "Lade {duration} vor Abfahrt zur Batterie-Vorkonditionierung."
|
||||
preconditionLong = "Spätes Laden"
|
||||
preconditionOptionAll = "alles"
|
||||
preconditionOptionNo = "nein"
|
||||
preconditionShort = "Spät"
|
||||
remove = "Entfernen"
|
||||
repeating = "wiederholend"
|
||||
repeatingPlans = "Wiederholende Pläne"
|
||||
|
|
|
|||
|
|
@ -591,6 +591,11 @@ goal = "Charging goal"
|
|||
modalTitle = "Charging Plan"
|
||||
none = "none"
|
||||
planNumber = "Plan {number}"
|
||||
preconditionDescription = "Charge {duration} before departure for battery preconditioning."
|
||||
preconditionLong = "Late Charging"
|
||||
preconditionOptionAll = "everything"
|
||||
preconditionOptionNo = "no"
|
||||
preconditionShort = "Late"
|
||||
remove = "Remove"
|
||||
repeating = "repeating"
|
||||
repeatingPlans = "Repeating plans"
|
||||
|
|
|
|||
|
|
@ -81,7 +81,7 @@ func (h *Hub) apply(ev Event, tmpl string) (string, error) {
|
|||
if v, err := h.vehicles.ByName(name); err == nil {
|
||||
attr["vehicleLimitSoc"] = v.GetLimitSoc()
|
||||
attr["vehicleMinSoc"] = v.GetMinSoc()
|
||||
attr["vehiclePlanTime"], attr["vehiclePlanSoc"] = v.GetPlanSoc()
|
||||
attr["vehiclePlanTime"], _, attr["vehiclePlanSoc"] = v.GetPlanSoc()
|
||||
|
||||
instance := v.Instance()
|
||||
attr["vehicleTitle"] = instance.Title()
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import (
|
|||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// pass converts a simple api without return value to api with nil error return value
|
||||
|
|
@ -28,6 +29,21 @@ func parseFloat(payload string) (float64, error) {
|
|||
return f, err
|
||||
}
|
||||
|
||||
// parseDuration parses a duration string as seconds
|
||||
func parseDuration(payload string) (time.Duration, error) {
|
||||
if payload == "" {
|
||||
return 0, nil
|
||||
}
|
||||
v, err := strconv.Atoi(payload)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if v < 0 {
|
||||
return 0, fmt.Errorf("invalid duration: %s", payload)
|
||||
}
|
||||
return time.Duration(v) * time.Second, err
|
||||
}
|
||||
|
||||
// jsonDecoder returns a json decoder with disallowed unknown fields
|
||||
func jsonDecoder(r io.Reader) *json.Decoder {
|
||||
dec := json.NewDecoder(r)
|
||||
|
|
|
|||
|
|
@ -27,7 +27,7 @@ func getLoadpointStaticConfig(lp loadpoint.API) loadpoint.StaticConfig {
|
|||
}
|
||||
|
||||
func getLoadpointDynamicConfig(lp loadpoint.API) loadpoint.DynamicConfig {
|
||||
planTime, planEnergy := lp.GetPlanEnergy()
|
||||
planTime, planPrecondition, planEnergy := lp.GetPlanEnergy()
|
||||
return loadpoint.DynamicConfig{
|
||||
Title: lp.GetTitle(),
|
||||
DefaultMode: string(lp.GetDefaultMode()),
|
||||
|
|
@ -40,6 +40,7 @@ func getLoadpointDynamicConfig(lp loadpoint.API) loadpoint.DynamicConfig {
|
|||
Soc: lp.GetSocConfig(),
|
||||
PlanEnergy: planEnergy,
|
||||
PlanTime: planTime,
|
||||
PlanPrecondition: int64(planPrecondition.Seconds()),
|
||||
LimitEnergy: lp.GetLimitEnergy(),
|
||||
LimitSoc: lp.GetLimitSoc(),
|
||||
}
|
||||
|
|
|
|||
|
|
@ -49,21 +49,24 @@ func planHandler(lp loadpoint.API) http.HandlerFunc {
|
|||
id := lp.EffectivePlanId()
|
||||
|
||||
goal, _ := lp.GetPlanGoal()
|
||||
precondition := lp.GetPlanPreCondDuration()
|
||||
requiredDuration := lp.GetPlanRequiredDuration(goal, maxPower)
|
||||
plan := lp.GetPlan(planTime, requiredDuration)
|
||||
plan := lp.GetPlan(planTime, requiredDuration, precondition)
|
||||
|
||||
res := struct {
|
||||
PlanId int `json:"planId"`
|
||||
PlanTime time.Time `json:"planTime"`
|
||||
Duration int64 `json:"duration"`
|
||||
Plan api.Rates `json:"plan"`
|
||||
Power float64 `json:"power"`
|
||||
PlanId int `json:"planId"`
|
||||
PlanTime time.Time `json:"planTime"`
|
||||
Duration int64 `json:"duration"`
|
||||
Precondition int64 `json:"precondition"`
|
||||
Plan api.Rates `json:"plan"`
|
||||
Power float64 `json:"power"`
|
||||
}{
|
||||
PlanId: id,
|
||||
PlanTime: planTime,
|
||||
Duration: int64(requiredDuration.Seconds()),
|
||||
Plan: plan,
|
||||
Power: maxPower,
|
||||
PlanId: id,
|
||||
PlanTime: planTime,
|
||||
Duration: int64(requiredDuration.Seconds()),
|
||||
Precondition: int64(precondition.Seconds()),
|
||||
Plan: plan,
|
||||
Power: maxPower,
|
||||
}
|
||||
|
||||
jsonResult(w, res)
|
||||
|
|
@ -74,6 +77,7 @@ func planHandler(lp loadpoint.API) http.HandlerFunc {
|
|||
func staticPlanPreviewHandler(lp loadpoint.API) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
vars := mux.Vars(r)
|
||||
query := r.URL.Query()
|
||||
|
||||
planTime, err := time.ParseInLocation(time.RFC3339, vars["time"], nil)
|
||||
if err != nil {
|
||||
|
|
@ -87,6 +91,12 @@ func staticPlanPreviewHandler(lp loadpoint.API) http.HandlerFunc {
|
|||
return
|
||||
}
|
||||
|
||||
precondition, err := parseDuration(query.Get("precondition"))
|
||||
if err != nil {
|
||||
jsonError(w, http.StatusBadRequest, err)
|
||||
return
|
||||
}
|
||||
|
||||
switch typ := vars["type"]; typ {
|
||||
case "soc":
|
||||
if !lp.SocBasedPlanning() {
|
||||
|
|
@ -105,18 +115,20 @@ func staticPlanPreviewHandler(lp loadpoint.API) http.HandlerFunc {
|
|||
|
||||
maxPower := lp.EffectiveMaxPower()
|
||||
requiredDuration := lp.GetPlanRequiredDuration(goal, maxPower)
|
||||
plan := lp.GetPlan(planTime, requiredDuration)
|
||||
plan := lp.GetPlan(planTime, requiredDuration, precondition)
|
||||
|
||||
res := struct {
|
||||
PlanTime time.Time `json:"planTime"`
|
||||
Duration int64 `json:"duration"`
|
||||
Plan api.Rates `json:"plan"`
|
||||
Power float64 `json:"power"`
|
||||
PlanTime time.Time `json:"planTime"`
|
||||
Duration int64 `json:"duration"`
|
||||
Precondition int64 `json:"precondition"`
|
||||
Plan api.Rates `json:"plan"`
|
||||
Power float64 `json:"power"`
|
||||
}{
|
||||
PlanTime: planTime,
|
||||
Duration: int64(requiredDuration.Seconds()),
|
||||
Plan: plan,
|
||||
Power: maxPower,
|
||||
PlanTime: planTime,
|
||||
Duration: int64(requiredDuration.Seconds()),
|
||||
Precondition: int64(precondition.Seconds()),
|
||||
Plan: plan,
|
||||
Power: maxPower,
|
||||
}
|
||||
|
||||
jsonResult(w, res)
|
||||
|
|
@ -126,6 +138,7 @@ func staticPlanPreviewHandler(lp loadpoint.API) http.HandlerFunc {
|
|||
func repeatingPlanPreviewHandler(lp loadpoint.API) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
vars := mux.Vars(r)
|
||||
query := r.URL.Query()
|
||||
|
||||
hourMinute := vars["time"]
|
||||
tz := vars["tz"]
|
||||
|
|
@ -152,20 +165,28 @@ func repeatingPlanPreviewHandler(lp loadpoint.API) http.HandlerFunc {
|
|||
return
|
||||
}
|
||||
|
||||
precondition, err := parseDuration(query.Get("precondition"))
|
||||
if err != nil {
|
||||
jsonError(w, http.StatusBadRequest, err)
|
||||
return
|
||||
}
|
||||
|
||||
maxPower := lp.EffectiveMaxPower()
|
||||
requiredDuration := lp.GetPlanRequiredDuration(soc, maxPower)
|
||||
plan := lp.GetPlan(planTime, requiredDuration)
|
||||
plan := lp.GetPlan(planTime, requiredDuration, precondition)
|
||||
|
||||
res := struct {
|
||||
PlanTime time.Time `json:"planTime"`
|
||||
Duration int64 `json:"duration"`
|
||||
Plan api.Rates `json:"plan"`
|
||||
Power float64 `json:"power"`
|
||||
PlanTime time.Time `json:"planTime"`
|
||||
Duration int64 `json:"duration"`
|
||||
Precondition int64 `json:"precondition"`
|
||||
Plan api.Rates `json:"plan"`
|
||||
Power float64 `json:"power"`
|
||||
}{
|
||||
PlanTime: planTime,
|
||||
Duration: int64(requiredDuration.Seconds()),
|
||||
Plan: plan,
|
||||
Power: maxPower,
|
||||
PlanTime: planTime,
|
||||
Duration: int64(requiredDuration.Seconds()),
|
||||
Precondition: int64(precondition.Seconds()),
|
||||
Plan: plan,
|
||||
Power: maxPower,
|
||||
}
|
||||
|
||||
jsonResult(w, res)
|
||||
|
|
@ -176,6 +197,7 @@ func repeatingPlanPreviewHandler(lp loadpoint.API) http.HandlerFunc {
|
|||
func planEnergyHandler(lp loadpoint.API) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
vars := mux.Vars(r)
|
||||
query := r.URL.Query()
|
||||
|
||||
ts, err := time.ParseInLocation(time.RFC3339, vars["time"], nil)
|
||||
if err != nil {
|
||||
|
|
@ -189,19 +211,27 @@ func planEnergyHandler(lp loadpoint.API) http.HandlerFunc {
|
|||
return
|
||||
}
|
||||
|
||||
if err := lp.SetPlanEnergy(ts, val); err != nil {
|
||||
precondition, err := parseDuration(query.Get("precondition"))
|
||||
if err != nil {
|
||||
jsonError(w, http.StatusBadRequest, err)
|
||||
return
|
||||
}
|
||||
|
||||
ts, energy := lp.GetPlanEnergy()
|
||||
if err := lp.SetPlanEnergy(ts, precondition, val); err != nil {
|
||||
jsonError(w, http.StatusBadRequest, err)
|
||||
return
|
||||
}
|
||||
|
||||
ts, precondition, energy := lp.GetPlanEnergy()
|
||||
|
||||
res := struct {
|
||||
Energy float64 `json:"energy"`
|
||||
Time time.Time `json:"time"`
|
||||
Energy float64 `json:"energy"`
|
||||
Precondition int64 `json:"precondition"`
|
||||
Time time.Time `json:"time"`
|
||||
}{
|
||||
Energy: energy,
|
||||
Time: ts,
|
||||
Energy: energy,
|
||||
Precondition: int64(precondition.Seconds()),
|
||||
Time: ts,
|
||||
}
|
||||
|
||||
jsonResult(w, res)
|
||||
|
|
@ -211,7 +241,7 @@ func planEnergyHandler(lp loadpoint.API) http.HandlerFunc {
|
|||
// planRemoveHandler removes plan time
|
||||
func planRemoveHandler(lp loadpoint.API) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
if err := lp.SetPlanEnergy(time.Time{}, 0); err != nil {
|
||||
if err := lp.SetPlanEnergy(time.Time{}, 0, 0); err != nil {
|
||||
jsonError(w, http.StatusBadRequest, err)
|
||||
return
|
||||
}
|
||||
|
|
|
|||
|
|
@ -73,6 +73,7 @@ func limitSocHandler(site site.API) http.HandlerFunc {
|
|||
func planSocHandler(site site.API) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
vars := mux.Vars(r)
|
||||
query := r.URL.Query()
|
||||
|
||||
v, err := site.Vehicles().ByName(vars["name"])
|
||||
if err != nil {
|
||||
|
|
@ -92,19 +93,27 @@ func planSocHandler(site site.API) http.HandlerFunc {
|
|||
return
|
||||
}
|
||||
|
||||
if err := v.SetPlanSoc(ts, soc); err != nil {
|
||||
precondition, err := parseDuration(query.Get("precondition"))
|
||||
if err != nil {
|
||||
jsonError(w, http.StatusBadRequest, err)
|
||||
return
|
||||
}
|
||||
|
||||
ts, soc = v.GetPlanSoc()
|
||||
if err := v.SetPlanSoc(ts, precondition, soc); err != nil {
|
||||
jsonError(w, http.StatusBadRequest, err)
|
||||
return
|
||||
}
|
||||
|
||||
ts, precondition, soc = v.GetPlanSoc()
|
||||
|
||||
res := struct {
|
||||
Soc int `json:"soc"`
|
||||
Time time.Time `json:"time"`
|
||||
Soc int `json:"soc"`
|
||||
Precondition int64 `json:"precondition"`
|
||||
Time time.Time `json:"time"`
|
||||
}{
|
||||
Soc: soc,
|
||||
Time: ts,
|
||||
Soc: soc,
|
||||
Precondition: int64(precondition.Seconds()),
|
||||
Time: ts,
|
||||
}
|
||||
|
||||
jsonResult(w, res)
|
||||
|
|
@ -152,7 +161,7 @@ func planSocRemoveHandler(site site.API) http.HandlerFunc {
|
|||
return
|
||||
}
|
||||
|
||||
if err := v.SetPlanSoc(time.Time{}, 0); err != nil {
|
||||
if err := v.SetPlanSoc(time.Time{}, 0, 0); err != nil {
|
||||
jsonError(w, http.StatusBadRequest, err)
|
||||
return
|
||||
}
|
||||
|
|
|
|||
|
|
@ -244,12 +244,13 @@ func (m *MQTT) listenLoadpointSetters(topic string, site site.API, lp loadpoint.
|
|||
{"batteryBoost", boolSetter(lp.SetBatteryBoost)},
|
||||
{"planEnergy", func(payload string) error {
|
||||
var plan struct {
|
||||
Time time.Time `json:"time"`
|
||||
Value float64 `json:"value"`
|
||||
Time time.Time `json:"time"`
|
||||
Precondition int64 `json:"precondition"`
|
||||
Value float64 `json:"value"`
|
||||
}
|
||||
err := json.Unmarshal([]byte(payload), &plan)
|
||||
if err == nil {
|
||||
err = lp.SetPlanEnergy(plan.Time, plan.Value)
|
||||
err = lp.SetPlanEnergy(plan.Time, time.Duration(plan.Precondition)*time.Second, plan.Value)
|
||||
}
|
||||
return err
|
||||
}},
|
||||
|
|
@ -280,12 +281,13 @@ func (m *MQTT) listenVehicleSetters(topic string, v vehicle.API) error {
|
|||
{"minSoc", intSetter(pass(v.SetMinSoc))},
|
||||
{"planSoc", func(payload string) error {
|
||||
var plan struct {
|
||||
Time time.Time `json:"time"`
|
||||
Value int `json:"value"`
|
||||
Time time.Time `json:"time"`
|
||||
Precondition int64 `json:"precondition"`
|
||||
Value int `json:"value"`
|
||||
}
|
||||
err := json.Unmarshal([]byte(payload), &plan)
|
||||
if err == nil {
|
||||
err = v.SetPlanSoc(plan.Time, plan.Value)
|
||||
err = v.SetPlanSoc(plan.Time, time.Duration(plan.Precondition)*time.Second, plan.Value)
|
||||
}
|
||||
return err
|
||||
}},
|
||||
|
|
|
|||
|
|
@ -1,10 +1,13 @@
|
|||
import { test, expect } from "@playwright/test";
|
||||
import { test, expect, devices } from "@playwright/test";
|
||||
import { start, stop, baseUrl, restart } from "./evcc";
|
||||
|
||||
test.use({ baseURL: baseUrl() });
|
||||
test.describe.configure({ mode: "parallel" });
|
||||
|
||||
const mobile = devices["iPhone 12 Mini"].viewport;
|
||||
|
||||
const CONFIG = "plan.evcc.yaml";
|
||||
const CONFIG_NO_TARIFF = "basics.evcc.yaml";
|
||||
|
||||
test.beforeEach(async () => {
|
||||
await start(CONFIG);
|
||||
|
|
@ -71,6 +74,11 @@ test.describe("basic functionality", async () => {
|
|||
await page.getByTestId("static-plan-day").selectOption({ index: 1 });
|
||||
await page.getByTestId("static-plan-time").fill("09:30");
|
||||
await page.getByTestId("static-plan-soc").selectOption("80%");
|
||||
await page.getByTestId("static-plan-precondition-lg-toggle").click();
|
||||
await page
|
||||
.getByTestId("static-plan-precondition-lg-select")
|
||||
.getByRole("combobox")
|
||||
.selectOption("1 hour");
|
||||
await page.getByTestId("static-plan-active").click();
|
||||
await page.getByRole("button", { name: "Close" }).click();
|
||||
|
||||
|
|
@ -574,6 +582,11 @@ test.describe("repeating", async () => {
|
|||
await plan.getByRole("checkbox", { name: "Select all" }).click(); // uncheck all
|
||||
await plan.getByRole("checkbox", { name: tomorrow }).check();
|
||||
await plan.getByTestId("repeating-plan-time").fill("09:20");
|
||||
await plan.getByTestId("repeating-plan-precondition-lg-toggle").click();
|
||||
await plan
|
||||
.getByTestId("repeating-plan-precondition-lg-select")
|
||||
.getByRole("combobox")
|
||||
.selectOption("2 hours");
|
||||
await plan.getByTestId("repeating-plan-active").click();
|
||||
await expect(modal.getByTestId("plan-preview-title")).toHaveText("Next plan #2");
|
||||
await expect(modal.getByTestId("target-text")).toContainText("9:20 AM");
|
||||
|
|
@ -595,5 +608,26 @@ test.describe("repeating", async () => {
|
|||
await expect(modal.getByTestId("plan-entry")).toHaveCount(2);
|
||||
await expect(modal.getByTestId("plan-preview-title")).toHaveText("Next plan #2");
|
||||
await expect(modal.getByTestId("target-text")).toContainText("9:20 AM");
|
||||
await expect(modal.getByTestId("repeating-plan-precondition-lg-toggle")).toBeChecked();
|
||||
await expect(
|
||||
modal.getByTestId("repeating-plan-precondition-lg-select").locator("option:checked")
|
||||
).toHaveText("2 hours");
|
||||
});
|
||||
});
|
||||
|
||||
// add test for precondition, start with basic.evcc.yaml and verify that precondition toggle element is not visible. make dedicated describe block
|
||||
test.describe("precondition", async () => {
|
||||
test("only if dynamic tariff exists", async ({ page }) => {
|
||||
await restart(CONFIG_NO_TARIFF);
|
||||
await page.goto("/");
|
||||
const lp1 = await page.getByTestId("loadpoint").first();
|
||||
await lp1.getByTestId("charging-plan").getByRole("button", { name: "none" }).click();
|
||||
await expect(page.getByTestId("static-plan-active")).toBeVisible();
|
||||
await expect(page.getByTestId("static-plan-precondition-lg-toggle")).not.toBeVisible();
|
||||
await expect(page.getByTestId("static-plan-precondition-lg-select")).not.toBeVisible();
|
||||
|
||||
// verify small viewport
|
||||
await page.setViewportSize(mobile);
|
||||
await expect(page.getByTestId("static-plan-precondition-select")).not.toBeVisible();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue