Add ui for price/co2 limit (#6935)

This commit is contained in:
Michael Geers 2023-04-05 13:14:56 +02:00 • committed by GitHub
parent 13bea1ee1b
commit b503f0152f
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
39 changed files with 1282 additions and 550 deletions

View file

@ -206,6 +206,7 @@ type Rates []Rate
type Tariff interface {
Unit() string
Rates() (Rates, error)
IsDynamic() bool
}
// AuthProvider is the ability to provide OAuth authentication through the ui

View file

@ -52,12 +52,14 @@
--evcc-transition-very-fast: 100ms;
--bs-primary: var(--evcc-darker-green);
--bs-primary-rgb: 58, 186, 44;
--bs-primary-rgb: 11, 166, 49;
--bs-body-font-size: 14px;
--bs-font-sans-serif: Montserrat, system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue",
Arial, "Noto Sans", "Liberation Sans", sans-serif, "Apple Color Emoji", "Segoe UI Emoji",
"Segoe UI Symbol", "Noto Color Emoji";
--bs-success: var(--evcc-primary);
--bs-success-rgb: var(--bs-primary-rgb);
}
:root.dark {
@ -105,6 +107,11 @@ h4 {
font-size: 1.25rem;
}
h5 {
font-size: 1rem;
font-weight: bold;
}
.large {
font-size: 1.25rem;
line-height: 1.2;
@ -198,6 +205,12 @@ a:hover {
--bs-btn-hover-color: var(--bs-gray-dark);
}
.accordion {
--bs-accordion-active-color: var(--bs-text);
--bs-accordion-active-bg: var(--evcc-background);
--bs-accordion-btn-active-icon: var(--bs-accordion-btn-icon);
}
.text-evcc {
color: var(--evcc-dark-green);
}

View file

@ -0,0 +1,31 @@
<script setup>
import ChargingPlan from "./ChargingPlan.vue";
const hoursFromNow = function (hours) {
const now = new Date();
now.setHours(now.getHours() + hours);
return now.toISOString();
};
</script>
<template>
<Story>
<Variant title="none">
<ChargingPlan id="1" />
</Variant>
<Variant title="target charge">
<ChargingPlan id="1" :targetTime="hoursFromNow(14)" />
</Variant>
<Variant title="price EUR limit">
<ChargingPlan id="1" :smartCostLimit="0.13" smartCostUnit="EUR" :tariffGrid="0.3" />
</Variant>
<Variant title="price SEK limit">
<ChargingPlan id="1" :smartCostLimit="0.43" smartCostUnit="SEK" :tariffGrid="0.32" />
</Variant>
<Variant title="co2 limit">
<ChargingPlan id="1" :smartCostLimit="400" smartCostUnit="gCO2eq" :tariffCo2="623" />
</Variant>
<Variant title="min soc">
<ChargingPlan id="1" :minSoc="30" :vehicleSoc="25" />
</Variant>
</Story>
</template>

View file

@ -0,0 +1,235 @@
<template>
<div class="text-center">
<LabelAndValue
class="root flex-grow-1"
:label="title"
:class="disabled ? 'opacity-0' : 'opacity-1'"
>
<h3 class="value m-0 d-block d-sm-flex align-items-baseline justify-content-center">
<button
class="value-button p-0"
:class="enabled ? 'evcc-default-text' : 'text-gray'"
:disabled="disabled"
@click="openModal"
>
<strong v-if="minSocEnabled">{{ minSocLabel }}</strong>
<strong v-else-if="targetChargeEnabled">{{ targetTimeLabel() }}</strong>
<span v-else>{{ $t("main.chargingPlan.none") }}</span>
</button>
</h3>
</LabelAndValue>
<Teleport to="body">
<div
:id="modalId"
ref="modal"
class="modal fade text-dark modal-xl"
data-bs-backdrop="true"
tabindex="-1"
role="dialog"
aria-hidden="true"
>
<div class="modal-dialog modal-dialog-centered" role="document">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">
{{ $t("main.chargingPlan.modalTitle") }}
</h5>
<button
type="button"
class="btn-close"
data-bs-dismiss="modal"
aria-label="Close"
></button>
</div>
<div class="modal-body pt-2">
<ul v-if="showTabs" class="nav nav-tabs">
<li class="nav-item">
<a
class="nav-link"
:class="{ active: departureTabActive }"
href="#"
@click.prevent="showDeatureTab"
>
{{ $t("main.chargingPlan.departureTab") }}
</a>
</li>
<li class="nav-item">
<a
class="nav-link"
:class="{ active: arrivalTabActive }"
href="#"
@click.prevent="showArrivalTab"
>
{{ $t("main.chargingPlan.arrivalTab") }} 🧪
</a>
</li>
</ul>
<div v-if="isModalVisible">
<TargetCharge
v-if="departureTabActive"
v-bind="targetCharge"
@target-time-updated="setTargetTime"
@target-time-removed="removeTargetTime"
/>
<ChargingPlanArrival
v-if="arrivalTabActive"
v-bind="chargingPlanArrival"
@minsoc-updated="setMinSoc"
/>
</div>
</div>
</div>
</div>
</div>
</Teleport>
</div>
</template>
<script>
import Modal from "bootstrap/js/dist/modal";
import LabelAndValue from "./LabelAndValue.vue";
import TargetCharge from "./TargetCharge.vue";
import ChargingPlanArrival from "./ChargingPlanArrival.vue";
import formatter from "../mixins/formatter";
import collector from "../mixins/collector";
export default {
name: "ChargingPlan",
components: { LabelAndValue, TargetCharge, ChargingPlanArrival },
mixins: [formatter, collector],
props: {
id: [String, Number],
planActive: Boolean,
targetTime: String,
targetSoc: Number,
targetEnergy: Number,
socBasedCharging: Boolean,
disabled: Boolean,
minSoc: Number,
vehicleSoc: Number,
vehicleName: String,
smartCostLimit: Number,
smartCostUnit: String,
},
emits: ["target-time-updated", "target-time-removed", "minsoc-updated"],
data: function () {
return {
modal: null,
isModalVisible: false,
activeTab: "departure",
};
},
computed: {
targetChargeEnabled: function () {
return this.targetTime;
},
enabled: function () {
return this.targetChargeEnabled || this.minSocEnabled;
},
minSocLabel: function () {
return `${Math.round(this.minSoc)} %`;
},
modalId: function () {
return `chargingPlanModal_${this.id}`;
},
title: function () {
if (this.minSocEnabled) {
return this.$t("main.chargingPlan.titleMinSoc");
}
if (this.targetChargeEnabled) {
return this.$t("main.chargingPlan.titleTargetCharge");
}
return this.$t("main.chargingPlan.title");
},
minSocEnabled: function () {
return this.minSoc >= this.vehicleSoc && this.$hiddenFeatures();
},
departureTabActive: function () {
return this.activeTab === "departure";
},
arrivalTabActive: function () {
return this.activeTab === "arrival";
},
targetCharge: function () {
return this.collectProps(TargetCharge);
},
chargingPlanArrival: function () {
return this.collectProps(ChargingPlanArrival);
},
showTabs: function () {
return this.$hiddenFeatures();
},
},
mounted() {
this.modal = Modal.getOrCreateInstance(this.$refs.modal);
this.$refs.modal.addEventListener("show.bs.modal", this.modalVisible);
this.$refs.modal.addEventListener("hidden.bs.modal", this.modalInvisible);
},
unmounted() {
this.$refs.modal?.removeEventListener("show.bs.modal", this.modalVisible);
this.$refs.modal?.removeEventListener("hidden.bs.modal", this.modalInvisible);
},
methods: {
modalVisible: function () {
this.isModalVisible = true;
},
modalInvisible: function () {
this.isModalVisible = false;
},
openModal() {
if (this.minSocEnabled) {
this.showArrivalTab();
}
if (this.targetChargeEnabled) {
this.showDeatureTab();
}
this.modal.show();
},
// not computed because it needs to update over time
targetTimeLabel: function () {
const targetDate = new Date(this.targetTime);
return this.$t("main.chargingPlan.activeLabel", {
time: this.fmtAbsoluteDate(targetDate),
});
},
showDeatureTab: function () {
this.activeTab = "departure";
},
showArrivalTab: function () {
this.activeTab = "arrival";
},
setTargetTime: function (targetTime) {
this.$emit("target-time-updated", targetTime);
this.modal.hide();
},
removeTargetTime: function () {
this.$emit("target-time-removed");
this.modal.hide();
},
setMinSoc: function (minSoc) {
this.$emit("minsoc-updated", minSoc);
},
},
};
</script>
<style scoped>
.value {
line-height: 1.2;
border: none;
}
.value-button {
font-size: 18px;
border: none;
background: none;
text-decoration: underline;
}
.root {
transition: opacity var(--evcc-transition-medium) linear;
}
.value:hover {
color: var(--bs-color-white);
}
</style>

View file

@ -0,0 +1,63 @@
<template>
<div class="root mt-4">
<div class="d-flex justify-content-between align-items-baseline mb-2">
<label :for="formId('minsoc')" class="pt-1">
{{ $t("main.loadpointSettings.minSoc.label") }}
</label>
<select
:id="formId('minsoc')"
v-model.number="selectedMinSoc"
class="minSocSelect form-select mb-2"
@change="changeMinSoc"
>
<option
v-for="soc in [0, 5, 10, 15, 20, 25, 30, 35, 40, 45, 50]"
:key="soc"
:value="soc"
>
{{ soc ? `${soc}%` : "--" }}
</option>
</select>
</div>
<small>
{{ $t("main.loadpointSettings.minSoc.description", [selectedMinSoc || "x"]) }}
</small>
</div>
</template>
<script>
export default {
name: "ChargingPlanArrival",
props: {
id: [String, Number],
minSoc: Number,
vehicleName: String,
},
emits: ["minsoc-updated"],
data: function () {
return { selectedMinSoc: this.minSoc };
},
watch: {
minSoc: function (value) {
this.selectedMinSoc = value;
},
},
methods: {
formId: function (name) {
return `chargingplan_${this.id}_${name}`;
},
changeMinSoc: function () {
this.$emit("minsoc-updated", this.selectedMinSoc);
},
},
};
</script>
<style scoped>
.root {
max-width: 300px;
}
.minSocSelect {
width: 100px;
}
</style>

View file

@ -13,6 +13,12 @@ import Energyflow from "./Energyflow.vue";
:homePower="800"
:loadpointsPower="4200"
:activeLoadpointsCount="3"
:tariffGrid="0.25"
:tariffFeedIn="0.08"
:tariffEffectivePrice="0.08"
smartCostUnit="EUR"
smartCostAvailable
currency="EUR"
siteTitle="Home"
:pv="[{ power: 5000 }, { power: 2300 }]"
/>
@ -27,6 +33,10 @@ import Energyflow from "./Energyflow.vue";
:homePower="2000"
:batteryPower="800"
:batterySoc="77"
:tariffGrid="0.25"
:tariffFeedIn="0.08"
:tariffEffectivePrice="0.08"
currency="EUR"
siteTitle="Home"
:battery="[
{ soc: 44.999, capacity: 13.3 },
@ -116,8 +126,33 @@ import Energyflow from "./Energyflow.vue";
:homePower="300"
:batteryPower="-100"
:batterySoc="55"
:tariffGrid="0.25"
:tariffFeedIn="0.08"
:tariffEffectivePrice="0.08"
currency="EUR"
siteTitle="Home"
/>
</Variant>
<Variant title="co2">
<Energyflow
gridConfigured
pvConfigured
:pvPower="7300"
:gridPower="-2300"
:homePower="800"
:loadpointsPower="4200"
:activeLoadpointsCount="3"
:tariffGrid="0.25"
:tariffFeedIn="0.08"
:tariffEffectivePrice="0.08"
:tariffCo2="723"
:tariffEffectiveCo2="0"
smartCostUnit="gCO2eq"
smartCostAvailable
currency="EUR"
siteTitle="Home"
:pv="[{ power: 5000 }, { power: 2300 }]"
/>
</Variant>
</Story>
</template>

View file

@ -77,7 +77,9 @@
:powerInKw="powerInKw"
:details="detailsValue(tariffGrid, tariffCo2)"
:detailsFmt="detailsFmt"
:detailsClickable="smartCostAvailable"
:detailsTooltip="detailsTooltip(tariffGrid, tariffCo2)"
@details-clicked="openGridSettingsModal"
/>
</div>
</div>
@ -146,22 +148,26 @@
</div>
</div>
</div>
<GridSettingsModal v-bind="gridSettings" />
</div>
</template>
<script>
import "@h2d2/shopicons/es/filled/square";
import Modal from "bootstrap/js/dist/modal";
import Visualization from "./Visualization.vue";
import EnergyflowEntry from "./EnergyflowEntry.vue";
import GridSettingsModal from "../GridSettingsModal.vue";
import formatter from "../../mixins/formatter";
import AnimatedNumber from "../AnimatedNumber.vue";
import settings from "../../settings";
import { showGridPrice, showGridCo2 } from "../../gridDetails";
import { CO2_UNIT } from "../../units";
import collector from "../../mixins/collector";
export default {
name: "Energyflow",
components: { Visualization, EnergyflowEntry, AnimatedNumber },
mixins: [formatter],
components: { Visualization, EnergyflowEntry, AnimatedNumber, GridSettingsModal },
mixins: [formatter, collector],
props: {
gridConfigured: Boolean,
gridPower: { type: Number, default: 0 },
@ -171,7 +177,7 @@ export default {
pvPower: { type: Number, default: 0 },
loadpointsPower: { type: Number, default: 0 },
activeLoadpointsCount: { type: Number, default: 0 },
batteryConfigured: Boolean,
batteryConfigured: { type: Boolean },
battery: { type: Array },
batteryPower: { type: Number, default: 0 },
batterySoc: { type: Number, default: 0 },
@ -181,10 +187,13 @@ export default {
tariffEffectivePrice: { type: Number },
tariffCo2: { type: Number },
tariffEffectiveCo2: { type: Number },
smartCostAvailable: { type: Boolean },
smartCostLimit: { type: Number },
smartCostUnit: { type: String },
currency: { type: String },
},
data: () => {
return { detailsOpen: false, detailsCompleteHeight: null };
return { detailsOpen: false, detailsCompleteHeight: null, gridSettingsModal: null };
},
computed: {
gridImport: function () {
@ -243,8 +252,17 @@ export default {
batteryFmt() {
return (soc) => `${Math.round(soc)}%`;
},
gridSettings() {
return this.collectProps(GridSettingsModal);
},
co2Available() {
return this.smartCostUnit === CO2_UNIT;
},
},
mounted() {
this.gridSettingsModal = Modal.getOrCreateInstance(
document.querySelector("#gridSettingsModal")
);
window.addEventListener("resize", this.updateHeight);
// height must be calculated in case of initially open details
if (settings.energyflowDetails) {
@ -266,18 +284,16 @@ export default {
return result;
},
detailsValue(price, co2) {
if (showGridPrice() && !isNaN(price)) {
return price;
}
if (showGridCo2() && !isNaN(co2)) {
if (this.co2Available) {
return co2;
}
return price;
},
detailsFmt(value) {
if (showGridPrice()) {
return this.fmtPricePerKWh(value, this.currency, true);
if (this.co2Available) {
return this.fmtCo2Short(value);
}
return this.fmtCo2Short(value);
return this.fmtPricePerKWh(value, this.currency, true);
},
kw: function (watt) {
return this.fmtKw(watt, this.powerInKw);
@ -290,6 +306,9 @@ export default {
updateHeight: function () {
this.detailsCompleteHeight = this.$refs.detailsInner.offsetHeight;
},
openGridSettingsModal() {
this.gridSettingsModal.show();
},
},
};
</script>

View file

@ -9,10 +9,17 @@
{{ name }}
</span>
<span class="text-end text-nowrap ps-1 fw-bold d-flex">
<div ref="details" class="fw-normal" data-bs-toggle="tooltip" @click.stop="">
<AnimatedNumber v-if="details !== undefined" :to="details" :format="detailsFmt" />
<div
ref="details"
class="fw-normal"
:class="{ 'text-decoration-underline': detailsClickable }"
data-bs-toggle="tooltip"
:tabindex="detailsClickable ? 0 : undefined"
@click="detailsClicked"
>
<AnimatedNumber v-if="!isNaN(details)" :to="details" :format="detailsFmt" />
</div>
<div ref="power" class="power" data-bs-toggle="tooltip" @click.stop="">
<div ref="power" class="power" data-bs-toggle="tooltip" @click="powerClicked">
<AnimatedNumber :to="power" :format="kw" />
</div>
</span>
@ -44,7 +51,9 @@ export default {
details: { type: Number },
detailsFmt: { type: Function },
detailsTooltip: { type: Array },
detailsClickable: { type: Boolean },
},
emits: ["details-clicked"],
data() {
return { powerTooltipInstance: null, detailsTooltipInstance: null };
},
@ -87,6 +96,9 @@ export default {
);
},
updateDetailsTooltip() {
if (this.detailsClickable) {
return;
}
this.detailsTooltipInstance = this.updateTooltip(
this.detailsTooltipInstance,
this.detailsTooltip,
@ -107,6 +119,19 @@ export default {
instance.setContent({ ".tooltip-inner": html });
return instance;
},
powerClicked: function ($event) {
if (this.powerTooltip) {
$event.stopPropagation();
}
},
detailsClicked: function ($event) {
if (this.detailsClickable || this.detailsTooltip) {
$event.stopPropagation();
}
if (this.detailsClickable) {
this.$emit("details-clicked");
}
},
},
};
</script>

View file

@ -64,23 +64,6 @@
"
/>
</FormRow>
<FormRow
id="settingsGridDetails"
:label="$t('settings.gridDetails.label')"
>
<SelectGroup
id="settingsGridDetails"
v-model="gridDetails"
class="w-100"
:options="
GRID_DETAILS.map((value) => ({
value,
name: $t(`settings.gridDetails.${value}`),
disabled: isDisabled(value),
}))
"
/>
</FormRow>
<FormRow id="telemetryEnabled" :label="$t('settings.telemetry.label')">
<TelemetrySettings :sponsor="sponsor" class="mt-1 mb-0" />
</FormRow>
@ -120,7 +103,6 @@ import SelectGroup from "./SelectGroup.vue";
import { getLocalePreference, setLocalePreference, LOCALES, removeLocalePreference } from "../i18n";
import { getThemePreference, setThemePreference, THEMES } from "../theme";
import { getUnits, setUnits, UNITS } from "../units";
import { getGridDetails, setGridDetails, GRID_DETAILS } from "../gridDetails";
import { getHiddenFeatures, setHiddenFeatures } from "../featureflags";
export default {
@ -128,8 +110,6 @@ export default {
components: { TelemetrySettings, FormRow, SelectGroup, SponsorTokenExpires },
props: {
sponsor: String,
hasPrice: Boolean,
hasCo2: Boolean,
sponsorTokenExpires: Number,
},
data: function () {
@ -137,11 +117,9 @@ export default {
theme: getThemePreference(),
language: getLocalePreference() || "",
unit: getUnits(),
gridDetails: getGridDetails(),
hiddenFeatures: getHiddenFeatures(),
THEMES,
UNITS,
GRID_DETAILS,
};
},
computed: {
@ -161,9 +139,6 @@ export default {
unit(value) {
setUnits(value);
},
gridDetails(value) {
setGridDetails(value);
},
theme(value) {
setThemePreference(value);
},
@ -179,10 +154,5 @@ export default {
}
},
},
methods: {
isDisabled(option) {
return (option === "co2" && !this.hasCo2) || (option === "price" && !this.hasPrice);
},
},
};
</script>

View file

@ -0,0 +1,340 @@
<template>
<Teleport to="body">
<div
id="gridSettingsModal"
ref="modal"
class="modal fade text-dark modal-xl"
data-bs-backdrop="true"
tabindex="-1"
role="dialog"
aria-hidden="true"
>
<div class="modal-dialog modal-dialog-centered" role="document">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">
{{ $t("gridSettings.modalTitle") }}
</h5>
<button
type="button"
class="btn-close"
data-bs-dismiss="modal"
aria-label="Close"
></button>
</div>
<div class="modal-body">
<div class="smartCostLimit mb-4">
<div class="d-flex justify-content-between align-items-baseline mb-2">
<label for="smartCostLimit" class="pt-1">
{{
isCo2
? $t("gridSettings.co2Limit")
: $t("gridSettings.priceLimit")
}}
</label>
<select
id="smartCostLimit"
v-model.number="selectedSmartCostLimit"
class="form-select smartCostLimitSelect"
@change="changeSmartCostLimit"
>
<option value="0">none</option>
<option
v-for="{ value, name } in costOptions"
:key="value"
:value="value"
>
{{ name }}
</option>
</select>
</div>
<small v-if="selectedSmartCostLimit > 0">
{{ $t("gridSettings.costLimitDescription") }}
</small>
</div>
<div class="justify-content-between mb-2 d-flex justify-content-between">
<div class="text-start">
<div class="label">
{{ $t("gridSettings.activeHoursLabel") }}
</div>
<div
class="value"
:class="
chargingSlots.length ? 'text-primary' : 'value-inactive'
"
>
{{
$t("gridSettings.activeHours", {
total: totalSlots.length,
charging: chargingSlots.length,
})
}}
</div>
</div>
<div class="text-end">
<div class="label">
<span v-if="activeSlot">{{ activeSlotName }}</span>
<span v-else-if="isCo2">{{ $t("gridSettings.co2Label") }}</span>
<span v-else>{{ $t("gridSettings.priceLabel") }}</span>
</div>
<div v-if="activeSlot" class="value text-primary">
{{ activeSlotCost }}
</div>
<div v-else-if="chargingSlots.length" class="value text-primary">
{{ chargingCostRange }}
</div>
<div v-else class="value value-inactive">
{{ totalCostRange }}
</div>
</div>
</div>
<TariffChart
v-if="tariff"
:slots="slots"
@slot-hovered="slotHovered"
@slot-selected="slotSelected"
/>
</div>
</div>
</div>
</div>
</Teleport>
</template>
<script>
import formatter from "../mixins/formatter";
import TariffChart from "./TariffChart.vue";
import { CO2_UNIT } from "../units";
import api from "../api";
export default {
name: "GridSettingsModal",
components: { TariffChart },
mixins: [formatter],
props: {
smartCostLimit: Number,
smartCostUnit: String,
},
data: function () {
return {
isModalVisible: false,
selectedSmartCostLimit: 0,
tariff: null,
startTime: null,
activeIndex: null,
};
},
computed: {
isCo2() {
return this.smartCostUnit === CO2_UNIT;
},
costOptions() {
const result = [];
const stepSize = this.optionStepSize;
for (let i = 1; i <= 100; i++) {
const value = stepSize * i;
const name = `< ${
this.isCo2
? this.fmtCo2Medium(value)
: this.fmtPricePerKWh(value, this.smartCostUnit)
}`;
result.push({ value, name });
}
return result;
},
optionStepSize() {
if (!this.tariff) {
return 1;
}
const { max } = this.costRange(this.totalSlots);
for (const scale of [0.1, 1, 10, 100, 1000]) {
if (max < scale) {
return scale / 100;
}
}
return 1;
},
slots() {
const result = [];
if (!this.tariff?.rates) return result;
const rates = this.convertDates(this.tariff.rates);
const oneHour = 60 * 60 * 1000;
for (let i = 0; i < 42; i++) {
const start = new Date(this.startTime.getTime() + oneHour * i);
const startHour = start.getHours();
start.setMinutes(0);
start.setSeconds(0);
start.setMilliseconds(0);
const end = new Date(start.getTime());
end.setHours(startHour + 1);
const endHour = end.getHours();
const day = this.weekdayShort(start);
// TODO: handle multiple matching time slots
const price = this.findSlotInRange(start, end, rates)?.price;
const charging = price < this.selectedSmartCostLimit;
const selectable = price !== undefined;
result.push({ day, price, startHour, endHour, charging, selectable });
}
return result;
},
totalSlots() {
return this.slots.filter((s) => s.price !== undefined);
},
chargingSlots() {
return this.totalSlots.filter((s) => s.charging);
},
totalCostRange() {
return this.fmtCostRange(this.costRange(this.totalSlots));
},
chargingCostRange() {
return this.fmtCostRange(this.costRange(this.chargingSlots));
},
activeSlot() {
return this.slots[this.activeIndex];
},
activeSlotName() {
if (this.activeSlot) {
const { day, startHour, endHour } = this.activeSlot;
const range = `${startHour}–${endHour}`;
return this.$t("main.targetChargePlan.timeRange", { day, range });
}
return null;
},
activeSlotCost() {
const price = this.activeSlot.price;
if (price === undefined) {
return this.$t("main.targetChargePlan.unknownPrice");
}
if (this.isCo2) {
return this.fmtCo2Medium(price);
}
return this.fmtPricePerKWh(price, this.smartCostUnit);
},
},
watch: {
isModalVisible(visible) {
if (visible) {
this.updateTariff();
}
},
smartCostLimit(limit) {
this.selectedSmartCostLimit = limit;
},
},
mounted() {
this.$refs.modal.addEventListener("show.bs.modal", this.modalVisible);
this.$refs.modal.addEventListener("hidden.bs.modal", this.modalInvisible);
},
unmounted() {
this.$refs.modal?.removeEventListener("show.bs.modal", this.modalVisible);
this.$refs.modal?.removeEventListener("hidden.bs.modal", this.modalInvisible);
},
methods: {
updateTariff: async function () {
try {
this.tariff = (await api.get(`tariff/planner`)).data.result;
this.startTime = new Date();
} catch (e) {
console.error(e);
}
},
modalVisible: function () {
this.isModalVisible = true;
},
modalInvisible: function () {
this.isModalVisible = false;
},
convertDates(list) {
if (!list?.length) {
return [];
}
return list.map((item) => {
return {
start: new Date(item.start),
end: new Date(item.end),
price: item.price,
};
});
},
findSlotInRange(start, end, slots) {
return slots.find((s) => {
if (s.start.getTime() < start.getTime()) {
return s.end.getTime() > start.getTime();
}
return s.start.getTime() < end.getTime();
});
},
costRange(slots) {
let min = undefined;
let max = undefined;
slots.forEach((slot) => {
min = min === undefined ? slot.price : Math.min(min, slot.price);
max = max === undefined ? slot.price : Math.max(max, slot.price);
});
return { min, max };
},
fmtCostRange({ min, max }) {
const fmtMin = this.isCo2
? this.fmtCo2Short(min)
: this.fmtPricePerKWh(min, this.smartCostUnit, true);
const fmtMax = this.isCo2
? this.fmtCo2Short(max)
: this.fmtPricePerKWh(max, this.smartCostUnit, true);
return `${fmtMin} - ${fmtMax}`;
},
slotHovered(index) {
this.activeIndex = index;
},
setSelectedSmartCostLimit(limit) {
if (limit === 0) {
this.selectedSmartCostLimit = 0;
return;
}
const nextOption = this.costOptions.find(({ value }) => value > limit);
if (nextOption) {
this.selectedSmartCostLimit = nextOption.value;
}
},
slotSelected(index) {
const price = this.slots[index].price;
if (price !== undefined) {
this.setSelectedSmartCostLimit(price);
this.saveSmartCostLimit(this.selectedSmartCostLimit);
}
},
changeSmartCostLimit($event) {
this.saveSmartCostLimit($event.target.value);
},
async saveSmartCostLimit(limit) {
try {
await api.post(`smartcostlimit/${encodeURIComponent(limit)}`);
} catch (err) {
console.error(err);
}
},
},
};
</script>
<style scoped>
.smartCostLimit {
max-width: 400px;
}
.smartCostLimitSelect {
width: auto;
}
.value {
font-size: 18px;
font-weight: bold;
}
.label {
color: var(--evcc-gray);
text-transform: uppercase;
}
.value-inactive {
color: var(--evcc-gray);
}
</style>

View file

@ -32,7 +32,6 @@
@maxcurrent-updated="setMaxCurrent"
@mincurrent-updated="setMinCurrent"
@phasesconfigured-updated="setPhasesConfigured"
@minsoc-updated="setMinSoc"
/>
<div
@ -106,6 +105,7 @@
@target-time-removed="removeTargetTime"
@change-vehicle="changeVehicle"
@remove-vehicle="removeVehicle"
@minsoc-updated="setMinSoc"
/>
</div>
</template>
@ -199,6 +199,10 @@ export default {
pvAction: String,
guardRemaining: Number,
guardAction: String,
smartCostLimit: Number,
smartCostUnit: String,
tariffGrid: Number,
tariffCo2: Number,
},
data() {
return {

View file

@ -23,47 +23,6 @@
</div>
<div class="modal-body">
<div class="container">
<h4
v-if="showMinSocSettings"
class="d-flex align-items-center mb-3 mt-0 text-evcc"
>
{{ $t("main.loadpointSettings.vehicle") }}
<shopicon-bold-car3 class="ms-2"></shopicon-bold-car3>
</h4>
<div v-if="showMinSocSettings" class="mb-3 row">
<label
:for="formId('minsoc')"
class="col-sm-4 col-form-label pt-0 pt-sm-1"
>
{{ $t("main.loadpointSettings.minSoc.label") }}
</label>
<div class="col-sm-8 pe-0">
<select
:id="formId('minsoc')"
v-model.number="selectedMinSoc"
class="form-select form-select-sm mb-2 w-50"
@change="changeMinSoc"
>
<option
v-for="soc in [
0, 5, 10, 15, 20, 25, 30, 35, 40, 45, 50,
]"
:key="soc"
:value="soc"
>
{{ soc ? `${soc}%` : "--" }}
</option>
</select>
<small>
{{
$t("main.loadpointSettings.minSoc.description", [
selectedMinSoc || "x",
])
}}
</small>
</div>
</div>
<h4
v-if="showConfigurablePhases || showCurrentSettings"
class="d-flex align-items-center mb-3 mt-4 text-evcc"
@ -235,18 +194,12 @@ export default {
minCurrent: Number,
title: String,
},
emits: [
"phasesconfigured-updated",
"maxcurrent-updated",
"mincurrent-updated",
"minsoc-updated",
],
emits: ["phasesconfigured-updated", "maxcurrent-updated", "mincurrent-updated"],
data: function () {
return {
selectedMaxCurrent: this.maxCurrent,
selectedMinCurrent: this.minCurrent,
selectedPhases: this.phasesConfigured,
selectedMinSoc: this.minSoc,
};
},
computed: {
@ -274,9 +227,6 @@ export default {
showCurrentSettings: function () {
return this.$hiddenFeatures();
},
showMinSocSettings: function () {
return this.$hiddenFeatures();
},
},
watch: {
maxCurrent: function (value) {
@ -305,9 +255,6 @@ export default {
changePhasesConfigured: function () {
this.$emit("phasesconfigured-updated", this.selectedPhases);
},
changeMinSoc: function () {
this.$emit("minsoc-updated", this.selectedMinSoc);
},
currentOptions: function (max, defaultCurrent = 16) {
const result = [];
const toValue = max ? 32 : this.maxCurrent;

View file

@ -12,6 +12,10 @@
v-bind="loadpoint"
:id="index + 1"
:vehicles="vehicles"
:smartCostLimit="smartCostLimit"
:smartCostUnit="smartCostUnit"
:tariffGrid="tariffGrid"
:tariffCo2="tariffCo2"
class="h-100"
:class="{ 'loadpoint-unselected': !selected(index) }"
@click="scrollTo(index)"
@ -43,6 +47,10 @@ export default {
props: {
loadpoints: Array,
vehicles: Array,
smartCostLimit: Number,
smartCostUnit: String,
tariffGrid: Number,
tariffCo2: Number,
},
data() {
return { selectedIndex: 0, snapTimeout: null };

View file

@ -17,6 +17,10 @@
class="mt-1 mt-sm-2 flex-grow-1"
:loadpoints="loadpoints"
:vehicles="vehicles"
:smartCostLimit="smartCostLimit"
:smartCostUnit="smartCostUnit"
:tariffGrid="tariffGrid"
:tariffCo2="tariffCo2"
/>
<VehcileSettingsModal />
<Footer v-bind="footer"></Footer>
@ -92,6 +96,9 @@ export default {
uploadProgress: Number,
sponsor: String,
sponsorTokenExpires: Number,
smartCostLimit: Number,
smartCostUnit: String,
smartCostAvailable: Boolean,
},
computed: {
energyflow: function () {
@ -119,12 +126,6 @@ export default {
const vehicleLogins = this.auth ? this.auth.vehicles : {};
return { vehicleLogins, ...this.collectProps(TopNavigation) };
},
hasPrice: function () {
return !isNaN(this.tariffGrid);
},
hasCo2: function () {
return !isNaN(this.tariffCo2);
},
showParkingLot: function () {
// work in progess
return false;

View file

@ -1,137 +1,83 @@
<template>
<div>
<LabelAndValue
class="root flex-grow-1"
:label="$t('main.targetCharge.title')"
:class="disabled ? 'opacity-0' : 'opacity-1'"
>
<button
class="btn btn-link p-0 value text-center"
:class="targetChargeEnabled ? 'evcc-default-text' : 'text-gray'"
:disabled="disabled"
@click="openModal"
>
<strong v-if="targetChargeEnabled">{{ targetTimeLabel() }}</strong>
<span v-else>{{ $t("main.targetCharge.setTargetTime") }}</span>
</button>
</LabelAndValue>
<Teleport to="body">
<div
:id="modalId"
ref="modal"
class="modal fade text-dark modal-xl"
data-bs-backdrop="true"
tabindex="-1"
role="dialog"
aria-hidden="true"
>
<div class="modal-dialog modal-dialog-centered" role="document">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">
{{ $t("main.targetCharge.modalTitle") }}
</h5>
<button
type="button"
class="btn-close"
data-bs-dismiss="modal"
aria-label="Close"
></button>
</div>
<form @submit.prevent="setTargetTime">
<div class="modal-body">
<div
class="form-group d-lg-flex align-items-baseline mb-2 justify-content-between"
>
<!-- eslint-disable vue/no-v-html -->
<label for="targetTimeLabel" class="mb-3 me-3">
<span v-if="socBasedCharging">
{{
$t("main.targetCharge.descriptionSoc", {
targetSoc,
})
}}
</span>
<span v-else>
{{
$t("main.targetCharge.descriptionEnergy", {
targetEnergy: targetEnergyFormatted,
})
}}
</span>
</label>
<!-- eslint-enable vue/no-v-html -->
<div class="d-flex justify-content-between date-selection">
<select v-model="selectedDay" class="form-select me-2">
<option
v-for="opt in dayOptions()"
:key="opt.value"
:value="opt.value"
>
{{ opt.name }}
</option>
</select>
<input
v-model="selectedTime"
type="time"
class="form-control ms-2 time-selection"
:step="60 * 5"
required
/>
</div>
</div>
<p class="mb-0">
<span v-if="timeInThePast" class="text-danger">
{{ $t("main.targetCharge.targetIsInThePast") }}
</span>
<span v-else-if="timeTooFarInTheFuture" class="text-secondary">
{{ $t("main.targetCharge.targetIsTooFarInTheFuture") }}
</span>
&nbsp;
</p>
<TargetChargePlan
v-if="targetChargePlanProps"
v-bind="targetChargePlanProps"
/>
</div>
<div class="modal-footer d-flex justify-content-between">
<button
type="button"
class="btn btn-outline-secondary"
data-bs-dismiss="modal"
:disabled="!targetTime"
@click="removeTargetTime"
>
{{ $t("main.targetCharge.remove") }}
</button>
<button
type="submit"
class="btn btn-primary"
data-bs-dismiss="modal"
:disabled="timeInThePast"
>
<span v-if="targetTime">
{{ $t("main.targetCharge.update") }}
</span>
<span v-else>
{{ $t("main.targetCharge.activate") }}
</span>
</button>
</div>
</form>
</div>
<form @submit.prevent="setTargetTime">
<div class="mt-4">
<div class="form-group d-lg-flex align-items-baseline mb-2 justify-content-between">
<!-- eslint-disable vue/no-v-html -->
<label for="targetTimeLabel" class="mb-3 me-3">
<span v-if="socBasedCharging">
{{
$t("main.targetCharge.descriptionSoc", {
targetSoc,
})
}}
</span>
<span v-else>
{{
$t("main.targetCharge.descriptionEnergy", {
targetEnergy: targetEnergyFormatted,
})
}}
</span>
</label>
<!-- eslint-enable vue/no-v-html -->
<div class="d-flex justify-content-between date-selection">
<select v-model="selectedDay" class="form-select me-2">
<option v-for="opt in dayOptions()" :key="opt.value" :value="opt.value">
{{ opt.name }}
</option>
</select>
<input
v-model="selectedTime"
type="time"
class="form-control ms-2 time-selection"
:step="60 * 5"
required
/>
</div>
</div>
</Teleport>
</div>
<p class="mb-0">
<span v-if="timeInThePast" class="d-block text-danger">
{{ $t("main.targetCharge.targetIsInThePast") }}
</span>
<span v-else-if="timeTooFarInTheFuture" class="d-block text-secondary">
{{ $t("main.targetCharge.targetIsTooFarInTheFuture") }}
</span>
<span v-if="costLimitExists" class="d-block text-secondary">
{{
$t("main.targetCharge.costLimitIgnore", {
limit: costLimitText,
})
}}
</span>
&nbsp;
</p>
<TargetChargePlan v-if="targetChargePlanProps" v-bind="targetChargePlanProps" />
<div class="d-flex justify-content-between mt-3">
<button
type="button"
class="btn btn-outline-secondary"
:disabled="!targetTime"
@click="removeTargetTime"
>
{{ $t("main.targetCharge.remove") }}
</button>
<button type="submit" class="btn btn-primary" :disabled="timeInThePast">
<span v-if="targetTime">
{{ $t("main.targetCharge.update") }}
</span>
<span v-else>
{{ $t("main.targetCharge.activate") }}
</span>
</button>
</div>
</div>
</form>
</template>
<script>
import Modal from "bootstrap/js/dist/modal";
import "@h2d2/shopicons/es/filled/plus";
import "@h2d2/shopicons/es/filled/edit";
import LabelAndValue from "./LabelAndValue.vue";
import { CO2_UNIT } from "../units";
import TargetChargePlan from "./TargetChargePlan.vue";
import api from "../api";
@ -142,7 +88,7 @@ const LAST_TARGET_TIME_KEY = "last_target_time";
export default {
name: "TargetCharge",
components: { LabelAndValue, TargetChargePlan },
components: { TargetChargePlan },
mixins: [formatter],
props: {
id: [String, Number],
@ -152,6 +98,8 @@ export default {
targetEnergy: Number,
socBasedCharging: Boolean,
disabled: Boolean,
smartCostLimit: Number,
smartCostUnit: String,
},
emits: ["target-time-updated", "target-time-removed"],
data: function () {
@ -160,11 +108,16 @@ export default {
selectedTime: null,
plan: {},
tariff: {},
modal: null,
isModalVisible: false,
activeTab: "time",
};
},
computed: {
timeTabActive: function () {
return this.activeTab === "time";
},
priceTabActive: function () {
return this.activeTab === "price";
},
targetChargeEnabled: function () {
return this.targetTime;
},
@ -185,9 +138,6 @@ export default {
selectedTargetTime: function () {
return new Date(`${this.selectedDay}T${this.selectedTime || "00:00"}`);
},
modalId: function () {
return `targetChargeModal_${this.id}`;
},
targetEnergyFormatted: function () {
return this.fmtKWh(this.targetEnergy * 1e3, true, true, 1);
},
@ -197,14 +147,34 @@ export default {
const { duration, unit, plan } = this.plan;
return rates ? { duration, rates, plan, unit, targetTime } : null;
},
},
watch: {
targetTimeLabel: function () {
const targetDate = new Date(this.targetTime);
return this.$t("main.targetCharge.activeLabel", {
time: this.fmtAbsoluteDate(targetDate),
tariffLowest: function () {
return this.tariff?.rates.reduce((res, slot) => {
return Math.min(res, slot.price);
}, Number.MAX_VALUE);
},
tariffHighest: function () {
return this.tariff?.rates.reduce((res, slot) => {
return Math.max(res, slot.price);
}, 0);
},
costLimitExists: function () {
return this.smartCostLimit !== 0;
},
costLimitText: function () {
if (this.isCo2) {
this.$t("main.targetCharge.co2Limit", {
co2: this.fmtCo2Short(this.smartCostLimit),
});
}
return this.$t("main.targetCharge.priceLimit", {
price: this.fmtPricePerKWh(this.smartCostLimit, this.smartCostUnit, true),
});
},
isCo2() {
return this.smartCostUnit === CO2_UNIT;
},
},
watch: {
targetTime() {
this.initInputFields();
this.updatePlan();
@ -218,29 +188,14 @@ export default {
targetEnergy() {
this.updatePlan();
},
isModalVisible() {
this.updatePlan();
},
},
mounted() {
this.modal = Modal.getOrCreateInstance(this.$refs.modal);
this.$refs.modal.addEventListener("show.bs.modal", this.modalVisible);
this.$refs.modal.addEventListener("hide.bs.modal", this.modalInvisible);
},
unmounted() {
this.$refs.modal.removeEventListener("show.bs.modal", this.modalVisible);
this.$refs.modal.removeEventListener("hide.bs.modal", this.modalInvisible);
this.initInputFields();
this.updatePlan();
},
methods: {
modalVisible: function () {
this.isModalVisible = true;
},
modalInvisible: function () {
this.isModalVisible = false;
},
updatePlan: async function () {
if (
this.isModalVisible &&
!this.timeInThePast &&
(this.targetEnergy || this.targetSoc) &&
!isNaN(this.selectedTargetTime)
@ -258,17 +213,6 @@ export default {
}
}
},
// not computed because it needs to update over time
targetTimeLabel: function () {
if (this.targetChargeEnabled) {
const targetDate = new Date(this.targetTime);
return this.$t("main.targetCharge.activeLabel", {
time: this.fmtAbsoluteDate(targetDate),
});
}
return this.$t("main.targetCharge.inactiveLabel");
},
defaultDate: function () {
const [hours, minutes] = (
window.localStorage[LAST_TARGET_TIME_KEY] || DEFAULT_TARGET_TIME
@ -329,26 +273,11 @@ export default {
removeTargetTime: function () {
this.$emit("target-time-removed");
},
openModal() {
this.modal.show();
this.$nextTick(this.initInputFields);
},
},
};
</script>
<style scoped>
.value {
font-size: 18px;
line-height: 1.2;
border: none;
}
.root {
transition: opacity var(--evcc-transition-medium) linear;
}
.value:hover {
color: var(--bs-color-white);
}
@media (min-width: 992px) {
.date-selection {
width: 370px;

View file

@ -56,6 +56,14 @@ const zoned = {
unit: "DKK",
targetTime: createDate(17),
};
const unknown = {
rates: co2.rates.slice(0, 16),
duration: 8695,
plan: [createRate(213, 4), createRate(336, 11), createRate(336, 12)],
unit: "gCO2eq",
targetTime: createDate(14),
};
</script>
<template>
@ -69,5 +77,8 @@ const zoned = {
<Variant title="zoned">
<TargetChargePlan v-bind="zoned" />
</Variant>
<Variant title="unknown">
<TargetChargePlan v-bind="unknown" />
</Variant>
</Story>
</template>

View file

@ -1,6 +1,6 @@
<template>
<div class="plan pt-2">
<div class="details justify-content-between mb-2 d-flex justify-content-between">
<div class="justify-content-between mb-2 d-flex justify-content-between">
<div class="text-start">
<div class="label">{{ $t("main.targetChargePlan.chargeDuration") }}</div>
<div class="value text-primary">{{ planDuration }}</div>
@ -16,39 +16,18 @@
</div>
</div>
</div>
<div class="chart">
<div
v-for="(slot, index) in slots"
:key="slot.start"
:data-index="index"
class="slot user-select-none"
:class="{ active: isActive(index), toLate: slot.toLate }"
@touchstart="activeIndex = index"
@mouseenter="activeIndex = index"
@touchend="activeIndex = null"
@mouseleave="activeIndex = null"
@mouseup="activeIndex = null"
>
<div class="slot-bar" :style="priceStyle(slot.price)">
<span v-if="slot.price === undefined" class="unknown">?</span>
</div>
<div class="slot-label">
{{ slot.startHour }}
<br />
<span v-if="slot.startHour === 0">{{ slot.day }}</span>
</div>
</div>
</div>
<TariffChart :slots="slots" @slot-hovered="slotHovered" />
</div>
</template>
<script>
import formatter from "../mixins/formatter";
import "@h2d2/shopicons/es/regular/eco1";
import "@h2d2/shopicons/es/regular/clock";
import { CO2_UNIT } from "../units";
import TariffChart from "./TariffChart.vue";
export default {
name: "TargetChargePlan",
components: { TariffChart },
mixins: [formatter],
props: {
duration: Number,
@ -64,24 +43,14 @@ export default {
planDuration() {
return this.fmtShortDuration(this.duration, true);
},
maxPrice() {
let result = 0;
this.slots
.map((s) => s.price)
.filter((price) => price !== undefined)
.forEach((price) => {
result = Math.max(result, price);
});
return result;
},
isCo2() {
return this.unit === "gCO2eq";
return this.unit === CO2_UNIT;
},
avgPrice() {
let hourSum = 0;
let priceSum = 0;
this.convertDates(this.plan).forEach((slot) => {
const hours = (slot.end.getTime() - slot.start.getTime()) / 36e5;
const hours = (slot.end.getTime() - slot.start.getTime()) / 3600000;
if (slot.price) {
hourSum += hours;
priceSum += hours * slot.price;
@ -109,12 +78,9 @@ export default {
},
slots() {
const result = [];
const rates = this.convertDates(this.rates);
const plan = this.convertDates(this.plan);
const oneHour = 60 * 60 * 1000;
for (let i = 0; i < 42; i++) {
const start = new Date(this.startTime.getTime() + oneHour * i);
const startHour = start.getHours();
@ -131,7 +97,6 @@ export default {
const charging = this.findSlotInRange(start, end, plan) != null;
result.push({ day, price, startHour, endHour, charging, toLate });
}
return result;
},
},
@ -141,22 +106,6 @@ export default {
},
},
methods: {
ratePrice(start, end) {
return this.rates?.find((r) => {
const rStart = new Date(r.start).getTime();
return start.getTime() <= rStart && rStart < end.getTime();
}).price;
},
isActive(index) {
return this.activeIndex !== null
? this.activeIndex === index
: this.slots[index].charging;
},
priceStyle(price) {
return {
height: price === undefined ? "100%" : `${(100 / this.maxPrice) * price}%`,
};
},
convertDates(list) {
if (!list?.length) {
return [];
@ -177,84 +126,14 @@ export default {
return s.start.getTime() < end.getTime();
});
},
slotHovered(index) {
this.activeIndex = index;
},
},
};
</script>
<style scoped>
.root {
overflow: hidden;
position: relative;
}
.chart {
display: flex;
height: 140px;
align-items: flex-end;
overflow-y: none;
padding-bottom: 45px;
}
.slot {
text-align: center;
padding: 4px;
height: 100%;
display: flex;
justify-content: flex-end;
flex-direction: column;
position: relative;
opacity: 1;
}
@media (max-width: 991px) {
.chart {
overflow-x: auto;
}
.slot {
width: 20px;
flex-grow: 0;
flex-shrink: 0;
}
}
@media (min-width: 992px) {
.chart {
overflow-x: none;
justify-content: stretch;
}
.slot {
flex-grow: 1;
flex-shrink: 1;
flex-basis: 1;
}
}
.slot-bar {
background-clip: content-box !important;
background: var(--bs-gray-light);
border-radius: 8px;
width: 100%;
align-items: center;
display: flex;
justify-content: center;
color: var(--bs-white);
}
.slot-label {
color: var(--bs-gray-light);
line-height: 1.1;
position: absolute;
top: 100%;
left: -50%;
width: 200%;
text-align: center;
}
.slot.active .slot-bar {
background: var(--bs-primary);
}
.slot.active .slot-label {
color: var(--bs-primary);
}
.slot.toLate {
opacity: 0.33;
}
.slot.active {
opacity: 1;
}
.value {
font-size: 18px;
font-weight: bold;
@ -263,7 +142,4 @@ export default {
color: var(--evcc-gray);
text-transform: uppercase;
}
.unknown {
margin: 0 -0.5rem;
}
</style>

View file

@ -1,63 +0,0 @@
<template>
<div if="plan" class="small text-muted">
<div>{{ $t("main.targetCharge.planDuration") }}: {{ planDuration }}</div>
<div>
{{ $t("main.targetCharge.planPeriodLabel") }}:
<span v-if="planStart && planEnd">
{{
$t("main.targetCharge.planPeriodValue", { start: planStart, end: planEnd })
}}</span
>
<span v-else>{{ $t("main.targetCharge.planUnknown") }}:</span>
</div>
</div>
</template>
<script>
import formatter from "../mixins/formatter";
export default {
name: "TargetChargePlanMinimal",
mixins: [formatter],
props: {
duration: Number,
plan: Array,
unit: String,
power: Number,
},
computed: {
planDuration() {
return this.fmtShortDuration(this.duration, true);
},
lastSlot() {
if (this.plan?.length) {
return this.plan[this.plan?.length - 1];
}
return null;
},
firstSlot() {
if (this.plan?.length) {
return this.plan[0];
}
return null;
},
planStart() {
if (this.firstSlot) {
return this.weekdayTime(new Date(this.firstSlot.start));
}
return null;
},
planEnd() {
if (this.lastSlot) {
return this.weekdayTime(new Date(this.lastSlot.end));
}
return null;
},
durationHours() {
return this.duration / 3.6e12;
},
},
};
</script>
<style scoped></style>

View file

@ -0,0 +1,173 @@
<template>
<div class="chart">
<div
v-for="(slot, index) in slots"
:key="slot.start"
:data-index="index"
class="slot user-select-none"
:class="{
active: isActive(index),
toLate: slot.toLate,
'cursor-pointer': slot.selectable,
}"
@touchstart="hoverSlot(index)"
@mouseenter="hoverSlot(index)"
@touchend="hoverSlot(null)"
@mouseleave="hoverSlot(null)"
@mouseup="hoverSlot(null)"
@click="selectSlot(index)"
>
<div class="slot-bar" :style="priceStyle(slot.price)">
<span v-if="slot.price === undefined" class="unknown">?</span>
</div>
<div class="slot-label">
{{ slot.startHour }}
<br />
<span v-if="slot.startHour === 0">{{ slot.day }}</span>
</div>
</div>
</div>
</template>
<script>
import formatter from "../mixins/formatter";
import { CO2_UNIT } from "../units";
export default {
name: "TariffChart",
mixins: [formatter],
props: {
slots: Array,
},
emits: ["slot-hovered", "slot-selected"],
data() {
return { activeIndex: null, startTime: new Date() };
},
computed: {
maxPrice() {
let result = 0;
this.slots
.map((s) => s.price)
.filter((price) => price !== undefined)
.forEach((price) => {
result = Math.max(result, price);
});
return result;
},
avgPrice() {
let sum = 0;
let count = 0;
this.slots.forEach((s) => {
if (s.price !== undefined) {
sum += s.price;
count++;
}
});
return sum / count;
},
isCo2() {
return this.unit === CO2_UNIT;
},
activeSlot() {
return this.slots[this.activeIndex];
},
},
methods: {
hoverSlot(index) {
this.activeIndex = index;
this.$emit("slot-hovered", index);
},
selectSlot(index) {
if (this.slots[index]?.selectable) {
this.$emit("slot-selected", index);
}
},
isActive(index) {
return this.activeIndex !== null
? this.activeIndex === index
: this.slots[index].charging;
},
priceStyle(price) {
const value = price === undefined ? this.avgPrice : price;
return {
height: `${(100 / this.maxPrice) * value}%`,
};
},
},
};
</script>
<style scoped>
.chart {
display: flex;
height: 140px;
align-items: flex-end;
overflow-y: none;
padding-bottom: 45px;
}
.slot {
text-align: center;
padding: 4px;
height: 100%;
display: flex;
justify-content: flex-end;
flex-direction: column;
position: relative;
opacity: 1;
}
@media (max-width: 991px) {
.chart {
overflow-x: auto;
}
.slot {
width: 20px;
flex-grow: 0;
flex-shrink: 0;
}
}
@media (min-width: 992px) {
.chart {
overflow-x: none;
justify-content: stretch;
}
.slot {
flex-grow: 1;
flex-shrink: 1;
flex-basis: 1;
}
}
.slot-bar {
background-clip: content-box !important;
background: var(--bs-gray-light);
border-radius: 8px;
width: 100%;
align-items: center;
display: flex;
justify-content: center;
color: var(--bs-white);
}
.slot-label {
color: var(--bs-gray-light);
line-height: 1.1;
position: absolute;
top: 100%;
left: -50%;
width: 200%;
text-align: center;
}
.slot.active .slot-bar {
background: var(--bs-primary);
}
.slot.active .slot-label {
color: var(--bs-primary);
}
.slot.toLate {
opacity: 0.33;
}
.slot.active {
opacity: 1;
}
.unknown {
margin: 0 -0.5rem;
}
</style>

View file

@ -124,8 +124,6 @@ export default {
},
},
sponsor: String,
hasPrice: Boolean,
hasCo2: Boolean,
sponsorTokenExpires: Number,
},
computed: {

View file

@ -83,11 +83,14 @@ const hoursFromNow = function (hours) {
<Vehicle v-bind="state" enabled :vehicleTargetSoc="80" :vehicleSoc="80" />
</Variant>
<Variant title="target charge planned">
<Vehicle v-bind="state" :targetTime="hoursFromNow(14)" mode="pv" />
<Vehicle v-bind="state" :targetTime="hoursFromNow(34)" mode="pv" />
</Variant>
<Variant title="target charge active">
<Vehicle v-bind="state" enabled charging :targetTime="hoursFromNow(14)" mode="pv" />
</Variant>
<Variant title="smart charge cost limit active">
<Vehicle v-bind="state" enabled charging :smartCostLimit="0.13" mode="pv" />
</Variant>
<Variant title="pv enable timer">
<Vehicle v-bind="state" pvAction="enable" :pvRemainingInterpolated="32" />
</Variant>

View file

@ -31,12 +31,13 @@
:extraValue="chargedSoc"
align="start"
/>
<TargetCharge
class="flex-grow-1 text-center target-charge"
v-bind="targetCharge"
:disabled="targetChargeDisabled"
<ChargingPlan
class="flex-grow-1 target-charge"
v-bind="chargingPlan"
:disabled="chargingPlanDisabled"
@target-time-updated="setTargetTime"
@target-time-removed="removeTargetTime"
@minsoc-updated="setMinSoc"
/>
<TargetSocSelect
v-if="socBasedCharging"
@ -65,7 +66,7 @@ import LabelAndValue from "./LabelAndValue.vue";
import VehicleTitle from "./VehicleTitle.vue";
import VehicleSoc from "./VehicleSoc.vue";
import VehicleStatus from "./VehicleStatus.vue";
import TargetCharge from "./TargetCharge.vue";
import ChargingPlan from "./ChargingPlan.vue";
import TargetSocSelect from "./TargetSocSelect.vue";
import TargetEnergySelect from "./TargetEnergySelect.vue";
import { distanceUnit, distanceValue } from "../units";
@ -77,7 +78,7 @@ export default {
VehicleSoc,
VehicleStatus,
LabelAndValue,
TargetCharge,
ChargingPlan,
TargetSocSelect,
TargetEnergySelect,
},
@ -112,6 +113,10 @@ export default {
guardAction: String,
guardRemainingInterpolated: Number,
vehicles: Array,
smartCostLimit: Number,
smartCostUnit: String,
tariffGrid: Number,
tariffCo2: Number,
},
emits: [
"target-time-removed",
@ -120,6 +125,7 @@ export default {
"target-energy-updated",
"change-vehicle",
"remove-vehicle",
"minsoc-updated",
],
data() {
return {
@ -136,8 +142,8 @@ export default {
vehicleTitleProps: function () {
return this.collectProps(VehicleTitle);
},
targetCharge: function () {
return this.collectProps(TargetCharge);
chargingPlan: function () {
return this.collectProps(ChargingPlan);
},
range: function () {
return distanceValue(this.vehicleRange);
@ -161,7 +167,7 @@ export default {
const value = this.socPerKwh * (this.chargedEnergy / 1e3);
return value > 1 ? `+${Math.round(value)}%` : null;
},
targetChargeDisabled: function () {
chargingPlanDisabled: function () {
if (!this.connected) {
return true;
}
@ -199,6 +205,9 @@ export default {
setTargetTime: function (targetTime) {
this.$emit("target-time-updated", targetTime);
},
setMinSoc: function (minSoc) {
this.$emit("minsoc-updated", minSoc);
},
removeTargetTime: function () {
this.$emit("target-time-removed");
},

View file

@ -146,11 +146,11 @@ export default {
},
mounted() {
this.$refs.modal.addEventListener("show.bs.modal", this.modalVisible);
this.$refs.modal.addEventListener("hide.bs.modal", this.modalInvisible);
this.$refs.modal.addEventListener("hidden.bs.modal", this.modalInvisible);
},
unmounted() {
this.$refs.modal.removeEventListener("show.bs.modal", this.modalVisible);
this.$refs.modal.removeEventListener("hide.bs.modal", this.modalInvisible);
this.$refs.modal?.removeEventListener("show.bs.modal", this.modalVisible);
this.$refs.modal?.removeEventListener("hidden.bs.modal", this.modalInvisible);
},
methods: {
productName({ Brand, Description }) {

View file

@ -165,3 +165,32 @@ describe("vehicle target soc", () => {
);
});
});
describe("smart grid charging", () => {
test("show clean energy message", () => {
expectStatus(
{
connected: true,
enabled: true,
charging: true,
tariffCo2: 400,
smartCostLimit: 500,
smartCostUnit: "gCO2eq",
},
"cleanEnergyCharging"
);
});
test("show cheap energy message", () => {
expectStatus(
{
connected: true,
enabled: true,
charging: true,
tariffGrid: 0.28,
smartCostLimit: 0.29,
currency: "EUR",
},
"cheapEnergyCharging"
);
});
});

View file

@ -4,6 +4,7 @@
<script>
import formatter from "../mixins/formatter";
import { CO2_UNIT } from "../units";
export default {
name: "VehicleStatus",
@ -24,6 +25,10 @@ export default {
guardAction: String,
guardRemainingInterpolated: Number,
targetChargeDisabled: Boolean,
smartCostLimit: Number,
smartCostUnit: String,
tariffGrid: Number,
tariffCo2: Number,
},
computed: {
phaseTimerActive() {
@ -40,6 +45,9 @@ export default {
guardTimerActive() {
return this.guardRemainingInterpolated > 0 && this.guardAction === "enable";
},
isCo2() {
return this.smartCostUnit === CO2_UNIT;
},
message: function () {
const t = (key, data) => {
return this.$t(`main.vehicleStatus.${key}`, data);
@ -68,6 +76,16 @@ export default {
}
}
// clean energy
if (this.charging && this.isCo2 && this.tariffCo2 < this.smartCostLimit) {
return t("cleanEnergyCharging");
}
// cheap energy
if (this.charging && !this.isCo2 && this.tariffGrid < this.smartCostLimit) {
return t("cheapEnergyCharging");
}
if (this.pvTimerActive && !this.enabled && this.pvAction === "enable") {
return t("pvEnable", {
remaining: this.fmtShortDuration(this.pvRemainingInterpolated, true),

View file

@ -1,22 +0,0 @@
import settings from "./settings";
const NONE = "none";
const PRICE = "price";
const CO2 = "co2";
export const GRID_DETAILS = [NONE, PRICE, CO2];
export function getGridDetails() {
return GRID_DETAILS.includes(settings.gridDetails) ? settings.gridDetails : NONE;
}
export function showGridPrice() {
return settings.gridDetails === PRICE;
}
export function showGridCo2() {
return settings.gridDetails === CO2;
}
export function setGridDetails(value) {
settings.gridDetails = value;
}

View file

@ -5,7 +5,6 @@ const SETTINGS_THEME = "settings_theme";
const SETTINGS_UNIT = "settings_unit";
const SETTINGS_HIDDEN_FEATURES = "settings_hidden_features";
const SETTINGS_ENERGYFLOW_DETAILS = "settings_energyflow_details";
const SETTINGS_GRID_DETAILS = "settings_grid_details";
function read(key) {
return window.localStorage[key];
@ -40,7 +39,6 @@ const settings = reactive({
locale: read(SETTINGS_LOCALE),
theme: read(SETTINGS_THEME),
unit: read(SETTINGS_UNIT),
gridDetails: read(SETTINGS_GRID_DETAILS),
hiddenFeatures: readBool(SETTINGS_HIDDEN_FEATURES),
energyflowDetails: readBool(SETTINGS_ENERGYFLOW_DETAILS),
});
@ -48,7 +46,6 @@ const settings = reactive({
watch(() => settings.locale, save(SETTINGS_LOCALE));
watch(() => settings.theme, save(SETTINGS_THEME));
watch(() => settings.unit, save(SETTINGS_UNIT));
watch(() => settings.gridDetails, save(SETTINGS_GRID_DETAILS));
watch(() => settings.hiddenFeatures, saveBool(SETTINGS_HIDDEN_FEATURES));
watch(() => settings.energyflowDetails, saveBool(SETTINGS_ENERGYFLOW_DETAILS));

View file

@ -4,6 +4,8 @@ const KM = "km";
const MILES = "mi";
export const UNITS = [KM, MILES];
export const CO2_UNIT = "gCO2eq";
const MILES_FACTOR = 0.6213711922;
function isMiles() {

View file

@ -234,9 +234,6 @@ tariffs:
grid:
type: fixed
price: 0.399 # EUR/kWh
# grid:
# type: awattar
# region: de
feedin:
type: fixed
price: 0.08 # EUR/kWh

View file

@ -693,7 +693,10 @@ func (site *Site) prepare() {
site.publish("prioritySoc", site.PrioritySoc)
site.publish("residualPower", site.ResidualPower)
site.publish("smartCostLimit", site.SmartCostLimit)
if tariff := site.GetTariff(PlannerTariff); tariff != nil {
site.publish("smartCostUnit", tariff.Unit())
site.publish("smartCostAvailable", tariff.IsDynamic())
}
site.publish("currency", site.tariffs.Currency.String())
site.publish("savingsSince", site.savings.Since())

View file

@ -56,6 +56,16 @@ modalUpdateNow = "Jetzt installieren"
modalUpdateStarted = "Starte die neue Version von evcc..."
modalUpdateStatusStart = "Installation gestartet:"
[gridSettings]
activeHours = "{charging} von {total}"
activeHoursLabel = "Aktive Stunden"
co2Label = "CO₂-Emission"
co2Limit = "CO₂-Grenze"
costLimitDescription = "Aktiviert vorübergehendes Schnellladen im PV-Modus."
modalTitle = "Smartes Netzladen"
priceLabel = "Energiepreis"
priceLimit = "Preisgrenze"
[header]
about = "Über"
blog = "Blog"
@ -68,6 +78,16 @@ settings = "Einstellungen"
[main]
vehicles = "Parkplatz"
[main.chargingPlan]
activeLabel = "{time}"
arrivalTab = "Ankunft"
departureTab = "Abfahrt"
modalTitle = "Ladeplanung"
none = "none"
title = "Plan"
titleMinSoc = "Min. Ladung"
titleTargetCharge = "Abfahrt"
[main.energyflow]
battery = "Batterie"
batteryCharge = "Batterie laden"
@ -128,15 +148,16 @@ logout = "abmelden"
[main.targetCharge]
activate = "Aktivieren"
activeLabel = "{time}"
co2Limit = "CO₂-Grenze von {co2}"
costLimitIgnore = "Die eingestellte {limit} wird in diesem Zeitraum ignoriert."
descriptionEnergy = "Bis wann sollen {targetEnergy} ins Fahrzeug geladen sein?"
descriptionSoc = "Wann soll das Fahrzeug auf {targetSoc}% geladen sein?"
inactiveLabel = "Zielzeit"
modalTitle = "Zielzeit festlegen"
planDuration = "Ladezeit"
planPeriodLabel = "Zeitraum"
planPeriodValue = "{start} bis {end}"
planUnknown = "noch unbekannt"
priceLimit = "Preisgrenze von {price}"
remove = "Entfernen"
setTargetTime = "keine"
targetIsInThePast = "Wähle einen Zeitpunkt in der Zukunft, Marty."
@ -177,6 +198,8 @@ vehicleTarget = "Fahrzeuglimit: {soc}%"
[main.vehicleStatus]
charging = "Ladevorgang aktiv…"
cheapEnergyCharging = "Günstige Energie verfügbar. Ladevorgang aktiv…"
cleanEnergyCharging = "Saubere Energie verfügbar. Ladevorgang aktiv…"
connected = "Verbunden."
disconnected = "Nicht verbunden."
guard = "Starte in {remaining}…"
@ -233,12 +256,6 @@ vehicle = "Fahrzeug"
[settings]
title = "Einstellungen"
[settings.gridDetails]
co2 = "CO₂"
label = "Netztarifdetails"
none = "keine"
price = "Preis"
[settings.hiddenFeatures]
label = "Experimentell"
value = "Experimentelle UI-Funktionen zeigen."

View file

@ -56,6 +56,16 @@ modalUpdateNow = "Install now"
modalUpdateStarted = "Starting the new version of evcc…"
modalUpdateStatusStart = "Installation started:"
[gridSettings]
activeHours = "{charging} of {total}"
activeHoursLabel = "Active hours"
co2Label = "CO₂ emission"
co2Limit = "CO₂ limit"
costLimitDescription = "Enables temporary fast-charging in solar mode."
modalTitle = "Smart Grid Charging"
priceLabel = "Energy price"
priceLimit = "Price limit"
[header]
about = "About"
blog = "Blog"
@ -68,6 +78,16 @@ settings = "Settings"
[main]
vehicles = "Parking"
[main.chargingPlan]
activeLabel = "{time}"
arrivalTab = "Arrival"
departureTab = "Departure"
modalTitle = "Charging Plan"
none = "none"
title = "Plan"
titleMinSoc = "Min charge"
titleTargetCharge = "Depature"
[main.energyflow]
battery = "Battery"
batteryCharge = "Battery charging"
@ -128,15 +148,16 @@ logout = "log out"
[main.targetCharge]
activate = "Activate"
activeLabel = "{time}"
co2Limit = "CO₂ limit of {co2}"
costLimitIgnore = "The configured {limit} will be ignored during this period."
descriptionEnergy = "Until when should {targetEnergy} be loaded into the vehicle?"
descriptionSoc = "When should the vehicle be charged to {targetSoc}%?"
inactiveLabel = "Target time"
modalTitle = "Set Target Time"
planDuration = "Charging time"
planPeriodLabel = "Period"
planPeriodValue = "{start} to {end}"
planUnknown = "not known yet"
priceLimit = "price limit of {price}"
remove = "Remove"
setTargetTime = "none"
targetIsInThePast = "Pick a time in the future, Marty."
@ -177,6 +198,8 @@ vehicleTarget = "Vehicle limit: {soc}%"
[main.vehicleStatus]
charging = "Charging…"
cheapEnergyCharging = "Cheap energy available. Charging…"
cleanEnergyCharging = "Clean energy available. Charging…"
connected = "Connected."
disconnected = "Disconnected."
guard = "Starting in {remaining}…"
@ -233,12 +256,6 @@ vehicle = "Vehicle"
[settings]
title = "Settings"
[settings.gridDetails]
co2 = "CO₂"
label = "Grid details"
none = "none"
price = "price"
[settings.hiddenFeatures]
label = "Experimental"
value = "Show experimental UI features."

View file

@ -514,6 +514,20 @@ func (m *MockTariff) EXPECT() *MockTariffMockRecorder {
return m.recorder
}
// IsDynamic mocks base method.
func (m *MockTariff) IsDynamic() bool {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "IsDynamic")
ret0, _ := ret[0].(bool)
return ret0
}
// IsDynamic indicates an expected call of IsDynamic.
func (mr *MockTariffMockRecorder) IsDynamic() *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "IsDynamic", reflect.TypeOf((*MockTariff)(nil).IsDynamic))
}
// Rates mocks base method.
func (m *MockTariff) Rates() (api.Rates, error) {
m.ctrl.T.Helper()

View file

@ -106,3 +106,8 @@ func (t *Awattar) Rates() (api.Rates, error) {
defer t.mux.Unlock()
return slices.Clone(t.data), outdatedError(t.updated, time.Hour)
}
// IsDynamic implements the api.Tariff interface
func (t *Awattar) IsDynamic() bool {
return true
}

View file

@ -123,3 +123,8 @@ func (t *ElectricityMaps) Rates() (api.Rates, error) {
return res, outdatedError(t.updated, time.Hour)
}
// IsDynamic implements the api.Tariff interface
func (t *ElectricityMaps) IsDynamic() bool {
return true
}

View file

@ -12,9 +12,10 @@ import (
)
type Fixed struct {
unit string
clock clock.Clock
zones fixed.Zones
unit string
clock clock.Clock
zones fixed.Zones
dynamic bool
}
var _ api.Tariff = (*Fixed)(nil)
@ -42,8 +43,9 @@ func NewFixedFromConfig(other map[string]interface{}) (api.Tariff, error) {
}
t := &Fixed{
unit: cc.Currency,
clock: clock.New(),
unit: cc.Currency,
clock: clock.New(),
dynamic: len(cc.Zones) > 1,
}
for _, z := range cc.Zones {
@ -138,3 +140,8 @@ func (t *Fixed) Rates() (api.Rates, error) {
return res, nil
}
// IsDynamic implements the api.Tariff interface
func (t *Fixed) IsDynamic() bool {
return t.dynamic
}

View file

@ -135,3 +135,8 @@ func (t *GrünStromIndex) Rates() (api.Rates, error) {
defer t.mux.Unlock()
return slices.Clone(t.data), outdatedError(t.updated, time.Hour)
}
// IsDynamic implements the api.Tariff interface
func (t *GrünStromIndex) IsDynamic() bool {
return true
}

View file

@ -102,3 +102,8 @@ func (t *Octopus) Rates() (api.Rates, error) {
defer t.mux.Unlock()
return slices.Clone(t.data), outdatedError(t.updated, time.Hour)
}
// IsDynamic implements the api.Tariff interface
func (t *Octopus) IsDynamic() bool {
return true
}

View file

@ -147,3 +147,8 @@ func (t *Tibber) Rates() (api.Rates, error) {
defer t.mux.Unlock()
return slices.Clone(t.data), outdatedError(t.updated, time.Hour)
}
// IsDynamic implements the api.Tariff interface
func (t *Tibber) IsDynamic() bool {
return true
}