Add ui for price/co2 limit (#6935)
This commit is contained in:
parent
13bea1ee1b
commit
b503f0152f
39 changed files with 1282 additions and 550 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
31
assets/js/components/ChargingPlan.story.vue
Normal file
31
assets/js/components/ChargingPlan.story.vue
Normal 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>
|
||||
235
assets/js/components/ChargingPlan.vue
Normal file
235
assets/js/components/ChargingPlan.vue
Normal 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>
|
||||
63
assets/js/components/ChargingPlanArrival.vue
Normal file
63
assets/js/components/ChargingPlanArrival.vue
Normal 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>
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
340
assets/js/components/GridSettingsModal.vue
Normal file
340
assets/js/components/GridSettingsModal.vue
Normal 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>
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
</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>
|
||||
|
||||
</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;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
173
assets/js/components/TariffChart.vue
Normal file
173
assets/js/components/TariffChart.vue
Normal 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>
|
||||
|
|
@ -124,8 +124,6 @@ export default {
|
|||
},
|
||||
},
|
||||
sponsor: String,
|
||||
hasPrice: Boolean,
|
||||
hasCo2: Boolean,
|
||||
sponsorTokenExpires: Number,
|
||||
},
|
||||
computed: {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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 }) {
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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));
|
||||
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
|
||||
|
|
|
|||
33
i18n/de.toml
33
i18n/de.toml
|
|
@ -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."
|
||||
|
|
|
|||
33
i18n/en.toml
33
i18n/en.toml
|
|
@ -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."
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue