From 832012ef150ead8a5b535bbd28c075d29c5cfd33 Mon Sep 17 00:00:00 2001 From: andig Date: Fri, 25 Apr 2025 14:10:09 +0200 Subject: [PATCH] Planner: add precondition phase before departure (#20431) --- api/plans.go | 11 +- .../components/ChargingPlans/ChargingPlan.vue | 23 ++- .../ChargingPlans/PlanRepeatingSettings.vue | 55 ++++++- .../ChargingPlans/PlanStaticSettings.vue | 71 ++++++++-- .../ChargingPlans/PlansRepeatingSettings.vue | 3 + .../ChargingPlans/PlansSettings.vue | 65 +++++---- .../ChargingPlans/PreconditionSelect.vue | 134 ++++++++++++++++++ assets/js/components/ChargingPlans/types.d.ts | 3 + assets/js/components/Loadpoints/Loadpoint.vue | 5 + .../js/components/Loadpoints/Loadpoints.vue | 2 + assets/js/components/Site/Site.vue | 3 +- assets/js/components/Vehicles/Vehicle.vue | 2 + assets/js/mixins/formatter.ts | 13 ++ assets/js/types/evcc.ts | 7 - assets/js/utils/convertRates.ts | 17 +-- assets/js/utils/forecast.ts | 1 + core/keys/loadpoint.go | 1 + core/loadpoint.go | 17 ++- core/loadpoint/api.go | 10 +- core/loadpoint/config.go | 3 +- core/loadpoint/mock.go | 37 +++-- core/loadpoint_api.go | 17 ++- core/loadpoint_effective.go | 36 ++--- core/loadpoint_plan.go | 29 +++- core/planner/planner.go | 57 +++++++- core/planner/planner_test.go | 77 ++++++++-- core/site_tariffs.go | 16 ++- core/site_vehicles.go | 9 +- core/vehicle/adapter.go | 13 +- core/vehicle/api.go | 4 +- core/vehicle/dummy.go | 6 +- core/vehicle/mock.go | 15 +- i18n/de.toml | 5 + i18n/en.toml | 5 + push/hub.go | 2 +- server/helper.go | 16 +++ server/http_config_loadpoint_handler.go | 3 +- server/http_loadpoint_handler.go | 102 ++++++++----- server/http_vehicle_handler.go | 23 ++- server/mqtt.go | 14 +- tests/plan.spec.js | 36 ++++- 41 files changed, 746 insertions(+), 222 deletions(-) create mode 100644 assets/js/components/ChargingPlans/PreconditionSelect.vue diff --git a/api/plans.go b/api/plans.go index dc2b038ad..2bbe2de76 100644 --- a/api/plans.go +++ b/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 } diff --git a/assets/js/components/ChargingPlans/ChargingPlan.vue b/assets/js/components/ChargingPlans/ChargingPlan.vue index 579b53eff..250b5145e 100644 --- a/assets/js/components/ChargingPlans/ChargingPlan.vue +++ b/assets/js/components/ChargingPlans/ChargingPlan.vue @@ -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, }, 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 { diff --git a/assets/js/components/ChargingPlans/PlanRepeatingSettings.vue b/assets/js/components/ChargingPlans/PlanRepeatingSettings.vue index b52179d29..7c1abe0a9 100644 --- a/assets/js/components/ChargingPlans/PlanRepeatingSettings.vue +++ b/assets/js/components/ChargingPlans/PlanRepeatingSettings.vue @@ -14,22 +14,27 @@
-
+
-
+
-
+
-
+
+ +
+
@@ -76,7 +81,7 @@ {{ $t("main.chargingPlan.goal") }}
-
+
+
+ +
+
+ +
+ +
+ +
@@ -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({ }); diff --git a/assets/js/components/ChargingPlans/types.d.ts b/assets/js/components/ChargingPlans/types.d.ts index 8a7475bff..6fed83a5d 100644 --- a/assets/js/components/ChargingPlans/types.d.ts +++ b/assets/js/components/ChargingPlans/types.d.ts @@ -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; } diff --git a/assets/js/components/Loadpoints/Loadpoint.vue b/assets/js/components/Loadpoints/Loadpoint.vue index adb630ca3..118105a08 100644 --- a/assets/js/components/Loadpoints/Loadpoint.vue +++ b/assets/js/components/Loadpoints/Loadpoint.vue @@ -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, }, 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() { diff --git a/assets/js/components/Loadpoints/Loadpoints.vue b/assets/js/components/Loadpoints/Loadpoints.vue index 37f5dc415..f1fe0b639 100644 --- a/assets/js/components/Loadpoints/Loadpoints.vue +++ b/assets/js/components/Loadpoints/Loadpoints.vue @@ -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, }, emits: ["index-changed"], data() { diff --git a/assets/js/components/Site/Site.vue b/assets/js/components/Site/Site.vue index d9330d12c..a54effb58 100644 --- a/assets/js/components/Site/Site.vue +++ b/assets/js/components/Site/Site.vue @@ -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, }, computed: { batteryConfigured() { diff --git a/assets/js/components/Vehicles/Vehicle.vue b/assets/js/components/Vehicles/Vehicle.vue index 8ec888814..9295a710d 100644 --- a/assets/js/components/Vehicles/Vehicle.vue +++ b/assets/js/components/Vehicles/Vehicle.vue @@ -111,12 +111,14 @@ export default { chargerStatusReason: String, phaseAction: String, phaseRemainingInterpolated: Number, + forecast: Object, // as PropType, planActive: Boolean, planEnergy: Number, planProjectedStart: String, planProjectedEnd: String, planTime: String, planTimeUnreachable: Boolean, + planPrecondition: Number, planOverrun: Number, pvAction: String, pvRemainingInterpolated: Number, diff --git a/assets/js/mixins/formatter.ts b/assets/js/mixins/formatter.ts index 8b39150fc..d3500844c 100644 --- a/assets/js/mixins/formatter.ts +++ b/assets/js/mixins/formatter.ts @@ -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"); diff --git a/assets/js/types/evcc.ts b/assets/js/types/evcc.ts index 93f50def4..d6039b247 100644 --- a/assets/js/types/evcc.ts +++ b/assets/js/types/evcc.ts @@ -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; diff --git a/assets/js/utils/convertRates.ts b/assets/js/utils/convertRates.ts index 433fb99d1..72c76c100 100644 --- a/assets/js/utils/convertRates.ts +++ b/assets/js/utils/convertRates.ts @@ -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); } diff --git a/assets/js/utils/forecast.ts b/assets/js/utils/forecast.ts index 81c0e5895..ee40761bd 100644 --- a/assets/js/utils/forecast.ts +++ b/assets/js/utils/forecast.ts @@ -31,6 +31,7 @@ export interface SolarDetails { export interface Forecast { grid?: ForecastSlot[]; co2?: ForecastSlot[]; + planner?: ForecastSlot[]; solar?: SolarDetails; } diff --git a/core/keys/loadpoint.go b/core/keys/loadpoint.go index e3277dbb9..56d412286 100644 --- a/core/keys/loadpoint.go +++ b/core/keys/loadpoint.go @@ -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) diff --git a/core/loadpoint.go b/core/loadpoint.go index 5dc725c38..cdd72aeeb 100644 --- a/core/loadpoint.go +++ b/core/loadpoint.go @@ -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 } diff --git a/core/loadpoint/api.go b/core/loadpoint/api.go index c8e04458f..e640523f6 100644 --- a/core/loadpoint/api.go +++ b/core/loadpoint/api.go @@ -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 diff --git a/core/loadpoint/config.go b/core/loadpoint/config.go index f69936bae..6357ea3d2 100644 --- a/core/loadpoint/config.go +++ b/core/loadpoint/config.go @@ -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) diff --git a/core/loadpoint/mock.go b/core/loadpoint/mock.go index 0f902e8a9..ca1e5d6ec 100644 --- a/core/loadpoint/mock.go +++ b/core/loadpoint/mock.go @@ -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. diff --git a/core/loadpoint_api.go b/core/loadpoint_api.go index dba09bae5..8e6ea653d 100644 --- a/core/loadpoint_api.go +++ b/core/loadpoint_api.go @@ -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() } diff --git a/core/loadpoint_effective.go b/core/loadpoint_effective.go index 3f727feca..f1feb1adc 100644 --- a/core/loadpoint_effective.go +++ b/core/loadpoint_effective.go @@ -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 } diff --git a/core/loadpoint_plan.go b/core/loadpoint_plan.go index 67bcc10f9..5d84bbfa4 100644 --- a/core/loadpoint_plan.go +++ b/core/loadpoint_plan.go @@ -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 } diff --git a/core/planner/planner.go b/core/planner/planner.go index cfa976057..c58a114cc 100644 --- a/core/planner/planner.go +++ b/core/planner/planner.go @@ -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 +} diff --git a/core/planner/planner_test.go b/core/planner/planner_test.go index 481e93c47..0fe43da45 100644 --- a/core/planner/planner_test.go +++ b/core/planner/planner_test.go @@ -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) diff --git a/core/site_tariffs.go b/core/site_tariffs.go index bd5d8dae7..270b65a61 100644 --- a/core/site_tariffs.go +++ b/core/site_tariffs.go @@ -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 diff --git a/core/site_vehicles.go b/core/site_vehicles.go index f946dcbf8..a20384c50 100644 --- a/core/site_vehicles.go +++ b/core/site_vehicles.go @@ -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() diff --git a/core/vehicle/adapter.go b/core/vehicle/adapter.go index a6fe514a3..e94f87b2e 100644 --- a/core/vehicle/adapter.go +++ b/core/vehicle/adapter.go @@ -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() diff --git a/core/vehicle/api.go b/core/vehicle/api.go index 1074ab128..31ff62c8c 100644 --- a/core/vehicle/api.go +++ b/core/vehicle/api.go @@ -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 diff --git a/core/vehicle/dummy.go b/core/vehicle/dummy.go index 706f88c71..98d53cad9 100644 --- a/core/vehicle/dummy.go +++ b/core/vehicle/dummy.go @@ -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 } diff --git a/core/vehicle/mock.go b/core/vehicle/mock.go index ceb6b9f97..fed22c4ad 100644 --- a/core/vehicle/mock.go +++ b/core/vehicle/mock.go @@ -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. diff --git a/i18n/de.toml b/i18n/de.toml index 8326964e3..71ea2528d 100644 --- a/i18n/de.toml +++ b/i18n/de.toml @@ -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" diff --git a/i18n/en.toml b/i18n/en.toml index 420f906e0..3738ef9b3 100644 --- a/i18n/en.toml +++ b/i18n/en.toml @@ -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" diff --git a/push/hub.go b/push/hub.go index 8b9843b5f..54266f25f 100644 --- a/push/hub.go +++ b/push/hub.go @@ -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() diff --git a/server/helper.go b/server/helper.go index ed6c84428..da270c65a 100644 --- a/server/helper.go +++ b/server/helper.go @@ -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) diff --git a/server/http_config_loadpoint_handler.go b/server/http_config_loadpoint_handler.go index 3eb2d2824..81858545d 100644 --- a/server/http_config_loadpoint_handler.go +++ b/server/http_config_loadpoint_handler.go @@ -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(), } diff --git a/server/http_loadpoint_handler.go b/server/http_loadpoint_handler.go index 54807f32b..f1486acad 100644 --- a/server/http_loadpoint_handler.go +++ b/server/http_loadpoint_handler.go @@ -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 } diff --git a/server/http_vehicle_handler.go b/server/http_vehicle_handler.go index cf3805519..907381c28 100644 --- a/server/http_vehicle_handler.go +++ b/server/http_vehicle_handler.go @@ -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 } diff --git a/server/mqtt.go b/server/mqtt.go index d5e5a4922..3e004bcdc 100644 --- a/server/mqtt.go +++ b/server/mqtt.go @@ -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 }}, diff --git a/tests/plan.spec.js b/tests/plan.spec.js index 8a68692fd..db223e3b7 100644 --- a/tests/plan.spec.js +++ b/tests/plan.spec.js @@ -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(); }); });