Add battery grid charging (#14855)
This commit is contained in:
parent
bbf989c3c0
commit
b0ea0582e2
37 changed files with 797 additions and 489 deletions
|
|
@ -31,7 +31,7 @@ func (r Rates) Sort() {
|
|||
// Current returns the rates current rate or error
|
||||
func (r Rates) Current(now time.Time) (Rate, error) {
|
||||
for _, rr := range r {
|
||||
if (rr.Start.Before(now) || rr.Start.Equal(now)) && rr.End.After(now) {
|
||||
if !rr.Start.After(now) && rr.End.After(now) {
|
||||
return rr, nil
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -184,6 +184,13 @@ a:hover {
|
|||
--bs-btn-border-width: 2px;
|
||||
}
|
||||
|
||||
.btn-reset {
|
||||
border: none;
|
||||
background: none;
|
||||
padding: 0;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.btn-primary,
|
||||
.btn-primary:focus {
|
||||
background-color: var(--bs-primary);
|
||||
|
|
|
|||
|
|
@ -1,252 +1,270 @@
|
|||
<template>
|
||||
<Teleport to="body">
|
||||
<div
|
||||
id="batterySettingsModal"
|
||||
ref="modal"
|
||||
class="modal fade text-dark modal-l"
|
||||
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("batterySettings.modalTitle") }}
|
||||
</h5>
|
||||
<button
|
||||
type="button"
|
||||
class="btn-close"
|
||||
data-bs-dismiss="modal"
|
||||
aria-label="Close"
|
||||
></button>
|
||||
<GenericModal
|
||||
id="batterySettingsModal"
|
||||
:title="$t('batterySettings.modalTitle')"
|
||||
size="xl"
|
||||
data-testid="battery-settings-modal"
|
||||
@open="modalVisible"
|
||||
@closed="modalInvisible"
|
||||
>
|
||||
<ul v-if="gridChargePossible" class="nav nav-tabs mb-4">
|
||||
<li class="nav-item">
|
||||
<a
|
||||
class="nav-link"
|
||||
:class="{ active: usageTabActive }"
|
||||
href="#"
|
||||
@click.prevent="showUsageTab"
|
||||
>
|
||||
{{ $t("batterySettings.usageTab") }}
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a
|
||||
class="nav-link"
|
||||
:class="{ active: gridTabActive }"
|
||||
href="#"
|
||||
@click.prevent="showGridTab"
|
||||
>
|
||||
{{ $t("batterySettings.gridChargeTab") }} 🧪
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<div class="row" v-show="usageTabActive">
|
||||
<p class="text-center text-md-start col-md-6 order-md-2 col-lg-3 order-lg-3 pt-lg-2">
|
||||
{{ $t("batterySettings.batteryLevel") }}:
|
||||
<strong>{{ fmtSoc(batterySoc) }}</strong>
|
||||
<small v-for="(line, index) in batteryDetails" :key="index" class="d-block">
|
||||
{{ line }}
|
||||
</small>
|
||||
</p>
|
||||
<div
|
||||
class="col-md-6 order-md-1 col-lg-3 order-lg-2 mb-5 mb-lg-0 battery justify-content-center justify-content-md-start"
|
||||
>
|
||||
<div class="batteryLimits">
|
||||
<CustomSelect
|
||||
id="batterySettingsBuffer"
|
||||
:options="bufferOptions"
|
||||
:selected="selectedBufferSoc"
|
||||
class="bufferSoc p-2 end-0"
|
||||
:class="{
|
||||
'bufferSoc--hidden': selectedBufferSoc === selectedPrioritySoc,
|
||||
}"
|
||||
:style="{ top: `${topHeight}%` }"
|
||||
@change="changeBufferSoc"
|
||||
>
|
||||
<span class="text-decoration-underline text-nowrap pe-none">
|
||||
{{ fmtSoc(selectedBufferSoc) }}
|
||||
</span>
|
||||
</CustomSelect>
|
||||
|
||||
<CustomSelect
|
||||
id="batterySettingsPriority"
|
||||
:options="priorityOptions"
|
||||
:selected="selectedPrioritySoc"
|
||||
class="prioritySoc p-2 end-0"
|
||||
:style="{ top: `${100 - bottomHeight}%` }"
|
||||
@change="changePrioritySoc"
|
||||
>
|
||||
<span class="text-decoration-underline text-nowrap pe-none">
|
||||
{{ fmtSoc(selectedPrioritySoc) }}
|
||||
</span>
|
||||
</CustomSelect>
|
||||
</div>
|
||||
<div class="progress me-md-0">
|
||||
<div
|
||||
class="bg-dark-green progress-bar text-light align-items-center"
|
||||
role="button"
|
||||
:style="{ height: `${topHeight}%` }"
|
||||
@click="toggleBufferStart"
|
||||
>
|
||||
<shopicon-regular-lightning
|
||||
size="m"
|
||||
class="icon"
|
||||
:style="iconStyle(topHeight)"
|
||||
></shopicon-regular-lightning>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div
|
||||
class="d-flex justify-content-between align-items-center align-items-sm-start pb-2 flex-column flex-sm-row-reverse"
|
||||
<div
|
||||
class="bg-darker-green progress-bar text-light align-items-center"
|
||||
:style="{ height: `${middleHeight}%` }"
|
||||
>
|
||||
<shopicon-regular-car3
|
||||
size="m"
|
||||
class="icon"
|
||||
:style="iconStyle(middleHeight)"
|
||||
></shopicon-regular-car3>
|
||||
</div>
|
||||
<div
|
||||
class="bg-darkest-green progress-bar text-light align-items-center"
|
||||
:style="{ height: `${bottomHeight}%` }"
|
||||
>
|
||||
<shopicon-regular-home
|
||||
size="m"
|
||||
class="icon"
|
||||
:style="iconStyle(bottomHeight)"
|
||||
></shopicon-regular-home>
|
||||
</div>
|
||||
<div
|
||||
class="batterySoc ps-0 bg-white pe-none"
|
||||
:style="{ top: `${100 - batterySoc}%` }"
|
||||
></div>
|
||||
<div
|
||||
class="bufferStartIndicator pe-none"
|
||||
:class="{
|
||||
'bufferStartIndicator--hidden':
|
||||
!selectedBufferStartSoc || selectedBufferSoc === 100,
|
||||
}"
|
||||
:style="{ top: `${bufferStartTop}%` }"
|
||||
>
|
||||
<div class="bufferStartIndicator__left"></div>
|
||||
<div class="bufferStartIndicator__right"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-12 order-md-3 col-lg-6 order-lg-1 legend pt-lg-2">
|
||||
<p class="d-flex">
|
||||
<shopicon-regular-lightning
|
||||
size="s"
|
||||
class="flex-shrink-0 me-2"
|
||||
></shopicon-regular-lightning>
|
||||
<span class="d-block">
|
||||
{{ $t("batterySettings.legendTopName") }}
|
||||
<i18n-t
|
||||
keypath="batterySettings.legendTopSubline"
|
||||
tag="small"
|
||||
class="d-block"
|
||||
scope="global"
|
||||
>
|
||||
<div class="battery mb-5 mb-sm-3 me-5 me-sm-0 w-sm-50">
|
||||
<div class="batteryLimits">
|
||||
<label
|
||||
class="bufferSoc p-2 end-0"
|
||||
role="button"
|
||||
:class="{
|
||||
'bufferSoc--hidden':
|
||||
selectedBufferSoc === selectedPrioritySoc,
|
||||
}"
|
||||
:style="{ top: `${topHeight}%` }"
|
||||
>
|
||||
<select
|
||||
:value="selectedBufferSoc"
|
||||
class="custom-select"
|
||||
@change="changeBufferSoc"
|
||||
>
|
||||
<option
|
||||
v-for="{ value, name, disabled } in bufferOptions"
|
||||
:key="value"
|
||||
:value="value"
|
||||
:disabled="disabled"
|
||||
>
|
||||
{{ name }}
|
||||
</option>
|
||||
</select>
|
||||
<span class="text-decoration-underline text-nowrap pe-none">
|
||||
{{ fmtSoc(selectedBufferSoc) }}
|
||||
</span>
|
||||
</label>
|
||||
<label
|
||||
class="prioritySoc p-2 end-0"
|
||||
role="button"
|
||||
:style="{ top: `${100 - bottomHeight}%` }"
|
||||
>
|
||||
<select
|
||||
:value="selectedPrioritySoc"
|
||||
class="custom-select"
|
||||
@change="changePrioritySoc"
|
||||
>
|
||||
<option
|
||||
v-for="{ value, name, disabled } in priorityOptions"
|
||||
:key="value"
|
||||
:value="value"
|
||||
:disabled="disabled"
|
||||
>
|
||||
{{ name }}
|
||||
</option>
|
||||
</select>
|
||||
<span class="text-decoration-underline text-nowrap pe-none">
|
||||
{{ fmtSoc(selectedPrioritySoc) }}
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="progress">
|
||||
<div
|
||||
class="bg-dark-green progress-bar text-light align-items-center"
|
||||
role="button"
|
||||
:style="{ height: `${topHeight}%` }"
|
||||
@click="toggleBufferStart"
|
||||
>
|
||||
<shopicon-regular-lightning
|
||||
size="m"
|
||||
class="icon"
|
||||
:style="iconStyle(topHeight)"
|
||||
></shopicon-regular-lightning>
|
||||
</div>
|
||||
<div
|
||||
class="bg-darker-green progress-bar text-light align-items-center"
|
||||
:style="{ height: `${middleHeight}%` }"
|
||||
>
|
||||
<shopicon-regular-car3
|
||||
size="m"
|
||||
class="icon"
|
||||
:style="iconStyle(middleHeight)"
|
||||
></shopicon-regular-car3>
|
||||
</div>
|
||||
<div
|
||||
class="bg-darkest-green progress-bar text-light align-items-center"
|
||||
:style="{ height: `${bottomHeight}%` }"
|
||||
>
|
||||
<shopicon-regular-home
|
||||
size="m"
|
||||
class="icon"
|
||||
:style="iconStyle(bottomHeight)"
|
||||
></shopicon-regular-home>
|
||||
</div>
|
||||
<div
|
||||
class="batterySoc ps-0 bg-white pe-none"
|
||||
:style="{ top: `${100 - batterySoc}%` }"
|
||||
></div>
|
||||
<div
|
||||
class="bufferStartIndicator pe-none"
|
||||
:class="{
|
||||
'bufferStartIndicator--hidden':
|
||||
!selectedBufferStartSoc ||
|
||||
selectedBufferSoc === 100,
|
||||
}"
|
||||
:style="{ top: `${bufferStartTop}%` }"
|
||||
>
|
||||
<div class="bufferStartIndicator__left"></div>
|
||||
<div class="bufferStartIndicator__right"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="legend me-sm-4 align-self-start w-sm-50">
|
||||
<p>
|
||||
{{ $t("batterySettings.batteryLevel") }}:
|
||||
<strong>{{ fmtSoc(batterySoc) }}</strong>
|
||||
<small
|
||||
v-for="(line, index) in batteryDetails"
|
||||
:key="index"
|
||||
class="d-block"
|
||||
>
|
||||
{{ line }}
|
||||
</small>
|
||||
</p>
|
||||
<p>{{ $t("batterySettings.legendTitle") }}</p>
|
||||
<p class="d-flex">
|
||||
<shopicon-regular-lightning
|
||||
size="s"
|
||||
class="flex-shrink-0 me-2"
|
||||
></shopicon-regular-lightning>
|
||||
<span class="d-block">
|
||||
{{ $t("batterySettings.legendTopName") }}
|
||||
<small v-if="selectedBufferSoc == 100" class="d-block">
|
||||
{{ $t("batterySettings.legendTopSubline") }}
|
||||
</small>
|
||||
<small v-else class="d-block">
|
||||
{{ $t("batterySettings.legendTopAutostart") }}
|
||||
<label
|
||||
for="bufferStartSelect"
|
||||
class="position-relative d-block"
|
||||
>
|
||||
<select
|
||||
id="bufferStartSelect"
|
||||
class="custom-select"
|
||||
:value="selectedBufferStartSoc"
|
||||
@change="changeBufferStart"
|
||||
>
|
||||
<option
|
||||
v-for="option in bufferStartOptions"
|
||||
:key="option.value"
|
||||
:value="option.value"
|
||||
>
|
||||
{{ option.name }}
|
||||
</option>
|
||||
</select>
|
||||
<span class="text-decoration-underline">
|
||||
{{ bufferStartOption.name }}
|
||||
</span>
|
||||
</label>
|
||||
</small>
|
||||
<template #soc>
|
||||
<CustomSelect
|
||||
id="batterySettingsBufferTop"
|
||||
class="custom-select-inline"
|
||||
:options="bufferOptions"
|
||||
:selected="selectedBufferSoc"
|
||||
@change="changeBufferSoc"
|
||||
>
|
||||
<span class="text-decoration-underline">
|
||||
{{ fmtSoc(selectedBufferSoc) }}
|
||||
</span>
|
||||
</p>
|
||||
<p class="d-flex">
|
||||
<shopicon-regular-car3
|
||||
size="s"
|
||||
class="flex-shrink-0 me-2"
|
||||
></shopicon-regular-car3>
|
||||
<span class="d-block">
|
||||
{{ $t("batterySettings.legendMiddleName") }}
|
||||
<small class="d-block">
|
||||
{{ $t("batterySettings.legendMiddleSubline") }}
|
||||
</small>
|
||||
</CustomSelect>
|
||||
</template>
|
||||
</i18n-t>
|
||||
|
||||
<small class="d-block">
|
||||
{{ $t("batterySettings.legendTopAutostart") }}
|
||||
<CustomSelect
|
||||
id="batterySettingsBufferStart"
|
||||
class="custom-select-inline"
|
||||
:selected="selectedBufferStartSoc"
|
||||
:options="bufferStartOptions"
|
||||
@change="changeBufferStart"
|
||||
>
|
||||
<span class="text-decoration-underline">
|
||||
{{ bufferStartOption.name }}
|
||||
</span>
|
||||
</CustomSelect>
|
||||
</small>
|
||||
</span>
|
||||
</p>
|
||||
<p class="d-flex">
|
||||
<shopicon-regular-car3
|
||||
size="s"
|
||||
class="flex-shrink-0 me-2"
|
||||
></shopicon-regular-car3>
|
||||
<span class="d-block">
|
||||
{{ $t("batterySettings.legendMiddleName") }}
|
||||
<i18n-t
|
||||
keypath="batterySettings.legendMiddleSubline"
|
||||
tag="small"
|
||||
class="d-block"
|
||||
scope="global"
|
||||
>
|
||||
<template #soc>
|
||||
<CustomSelect
|
||||
id="batterySettingsPriorityMiddle"
|
||||
class="custom-select-inline"
|
||||
:options="priorityOptions"
|
||||
:selected="selectedPrioritySoc"
|
||||
@change="changePrioritySoc"
|
||||
>
|
||||
<span class="text-decoration-underline">
|
||||
{{ fmtSoc(selectedPrioritySoc) }}
|
||||
</span>
|
||||
</p>
|
||||
<p class="d-flex">
|
||||
<shopicon-regular-home
|
||||
size="s"
|
||||
class="flex-shrink-0 me-2"
|
||||
></shopicon-regular-home>
|
||||
<span class="d-block">
|
||||
{{ $t("batterySettings.legendBottomName") }}
|
||||
<small class="d-block">
|
||||
{{ $t("batterySettings.legendBottomSubline") }}
|
||||
</small>
|
||||
</CustomSelect>
|
||||
</template>
|
||||
</i18n-t>
|
||||
</span>
|
||||
</p>
|
||||
<p class="d-flex">
|
||||
<shopicon-regular-home
|
||||
size="s"
|
||||
class="flex-shrink-0 me-2"
|
||||
></shopicon-regular-home>
|
||||
<span class="d-block">
|
||||
{{ $t("batterySettings.legendBottomName") }}
|
||||
<i18n-t
|
||||
keypath="batterySettings.legendBottomSubline"
|
||||
tag="small"
|
||||
class="d-block"
|
||||
scope="global"
|
||||
>
|
||||
<template #soc>
|
||||
<CustomSelect
|
||||
id="batterySettingsPriorityBottom"
|
||||
class="custom-select-inline"
|
||||
:options="priorityOptions"
|
||||
:selected="selectedPrioritySoc"
|
||||
@change="changePrioritySoc"
|
||||
>
|
||||
<span class="text-decoration-underline">
|
||||
{{ fmtSoc(selectedPrioritySoc) }}
|
||||
</span>
|
||||
</p>
|
||||
<p class="small text-muted">
|
||||
<strong class="text-evcc">
|
||||
{{ $t("batterySettings.disclaimerHint") }}
|
||||
</strong>
|
||||
{{ $t("batterySettings.disclaimerText") }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="controllable">
|
||||
<div class="form-check form-switch">
|
||||
<input
|
||||
id="batteryDischargeControl"
|
||||
:checked="batteryDischargeControl"
|
||||
class="form-check-input"
|
||||
type="checkbox"
|
||||
role="switch"
|
||||
@change="changeDischargeControl"
|
||||
/>
|
||||
<div class="form-check-label">
|
||||
<label for="batteryDischargeControl">
|
||||
{{ $t("batterySettings.discharge") }}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CustomSelect>
|
||||
</template>
|
||||
</i18n-t>
|
||||
</span>
|
||||
</p>
|
||||
<div class="form-check form-switch mt-4" v-if="controllable">
|
||||
<input
|
||||
id="batteryDischargeControl"
|
||||
:checked="batteryDischargeControl"
|
||||
class="form-check-input"
|
||||
type="checkbox"
|
||||
role="switch"
|
||||
@change="changeDischargeControl"
|
||||
/>
|
||||
<div class="form-check-label">
|
||||
<label for="batteryDischargeControl">
|
||||
{{ $t("batterySettings.discharge") }}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
<SmartCostLimit
|
||||
v-if="gridChargePossible"
|
||||
v-show="gridTabActive"
|
||||
v-bind="smartCostLimitProps"
|
||||
/>
|
||||
</GenericModal>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import "@h2d2/shopicons/es/regular/lightning";
|
||||
import "@h2d2/shopicons/es/regular/car3";
|
||||
import "@h2d2/shopicons/es/regular/home";
|
||||
import SmartCostLimit from "./SmartCostLimit.vue";
|
||||
import CustomSelect from "./CustomSelect.vue";
|
||||
import GenericModal from "./GenericModal.vue";
|
||||
import formatter from "../mixins/formatter";
|
||||
import collector from "../mixins/collector";
|
||||
import api from "../api";
|
||||
import smartCostAvailable from "../utils/smartCostAvailable";
|
||||
|
||||
export default {
|
||||
name: "BatterySettingsModal",
|
||||
mixins: [formatter, collector],
|
||||
components: { SmartCostLimit, CustomSelect, GenericModal },
|
||||
props: {
|
||||
bufferSoc: Number,
|
||||
prioritySoc: Number,
|
||||
|
|
@ -254,6 +272,10 @@ export default {
|
|||
bufferStartSoc: Number,
|
||||
batteryDischargeControl: Boolean,
|
||||
battery: { type: Array, default: () => [] },
|
||||
batteryGridChargeLimit: Number,
|
||||
smartCostType: String,
|
||||
tariffGrid: Number,
|
||||
currency: String,
|
||||
},
|
||||
data: function () {
|
||||
return {
|
||||
|
|
@ -261,9 +283,13 @@ export default {
|
|||
selectedBufferSoc: 100,
|
||||
selectedPrioritySoc: 0,
|
||||
selectedBufferStartSoc: 0,
|
||||
gridTabActive: false,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
usageTabActive() {
|
||||
return !this.gridTabActive;
|
||||
},
|
||||
priorityOptions() {
|
||||
const options = [];
|
||||
for (let i = 100; i >= 0; i -= 5) {
|
||||
|
|
@ -278,6 +304,14 @@ export default {
|
|||
controllable() {
|
||||
return this.battery.some(({ controllable }) => controllable);
|
||||
},
|
||||
gridChargePossible() {
|
||||
return (
|
||||
this.controllable &&
|
||||
this.isModalVisible &&
|
||||
this.smartCostAvailable &&
|
||||
this.$hiddenFeatures()
|
||||
);
|
||||
},
|
||||
bufferOptions() {
|
||||
const options = [];
|
||||
for (let i = 100; i >= 5; i -= 5) {
|
||||
|
|
@ -345,6 +379,15 @@ export default {
|
|||
return `${name}${formattedEnergy}${formattedSoc}`;
|
||||
});
|
||||
},
|
||||
smartCostLimitProps() {
|
||||
return {
|
||||
...this.collectProps(SmartCostLimit),
|
||||
smartCostLimit: this.batteryGridChargeLimit,
|
||||
};
|
||||
},
|
||||
smartCostAvailable() {
|
||||
return smartCostAvailable(this.smartCostType);
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
prioritySoc(soc) {
|
||||
|
|
@ -361,14 +404,14 @@ export default {
|
|||
this.selectedBufferSoc = this.bufferSoc || 100;
|
||||
this.selectedPrioritySoc = this.prioritySoc;
|
||||
this.selectedBufferStartSoc = this.bufferStartSoc;
|
||||
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: {
|
||||
showGridTab() {
|
||||
this.gridTabActive = true;
|
||||
},
|
||||
showUsageTab() {
|
||||
this.gridTabActive = false;
|
||||
},
|
||||
modalVisible: function () {
|
||||
this.isModalVisible = true;
|
||||
},
|
||||
|
|
@ -451,14 +494,10 @@ export default {
|
|||
|
||||
<style scoped>
|
||||
.battery {
|
||||
height: 300px;
|
||||
height: 285px;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.legend {
|
||||
min-width: 260px;
|
||||
}
|
||||
|
||||
.batteryLimits {
|
||||
width: 50px;
|
||||
position: relative;
|
||||
|
|
@ -467,7 +506,7 @@ export default {
|
|||
.bufferStart,
|
||||
.bufferSoc,
|
||||
.prioritySoc {
|
||||
position: absolute;
|
||||
position: absolute !important;
|
||||
transform: translateY(-50%);
|
||||
transition-property: top, opacity;
|
||||
transition-timing-function: linear;
|
||||
|
|
@ -523,7 +562,8 @@ export default {
|
|||
flex: 1;
|
||||
height: 100%;
|
||||
min-width: 100px;
|
||||
max-width: 150px;
|
||||
max-width: 130px;
|
||||
margin-right: 50px;
|
||||
flex-direction: column;
|
||||
position: relative;
|
||||
border-radius: 1rem;
|
||||
|
|
@ -537,14 +577,7 @@ export default {
|
|||
z-index: 1;
|
||||
border-radius: 0.5rem;
|
||||
}
|
||||
.custom-select {
|
||||
left: 0;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
right: 0;
|
||||
cursor: pointer;
|
||||
position: absolute;
|
||||
opacity: 0;
|
||||
-webkit-appearance: menulist-button;
|
||||
.custom-select-inline {
|
||||
display: inline-block !important;
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -57,11 +57,7 @@
|
|||
</div>
|
||||
</div>
|
||||
</FormRow>
|
||||
<FormRow
|
||||
v-if="fullscreenAvailable"
|
||||
id="settingsFullscreen"
|
||||
:label="$t('settings.fullscreen.label')"
|
||||
>
|
||||
<FormRow v-if="fullscreenAvailable" :label="$t('settings.fullscreen.label')">
|
||||
<button
|
||||
v-if="fullscreenActive"
|
||||
class="btn btn-sm btn-outline-secondary"
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@ export default {
|
|||
name: "CustomSelect",
|
||||
props: {
|
||||
options: { type: Array },
|
||||
selected: { type: String },
|
||||
selected: { type: [String, Number] },
|
||||
id: { type: String },
|
||||
},
|
||||
emits: ["change"],
|
||||
|
|
|
|||
|
|
@ -160,7 +160,17 @@
|
|||
:detailsFmt="batteryFmt"
|
||||
detailsClickable
|
||||
@details-clicked="openBatterySettingsModal"
|
||||
/>
|
||||
>
|
||||
<template v-if="batteryGridChargeActive" #subline>
|
||||
<button
|
||||
type="button"
|
||||
class="btn-reset d-flex justify-content-between"
|
||||
@click.stop="openBatterySettingsModal"
|
||||
>
|
||||
{{ batteryGridChargeText }} (≤ {{ batteryGridChargeLimitFmt }})
|
||||
</button>
|
||||
</template>
|
||||
</EnergyflowEntry>
|
||||
<EnergyflowEntry
|
||||
v-if="pvPossible"
|
||||
:name="$t('main.energyflow.pvExport')"
|
||||
|
|
@ -211,6 +221,8 @@ export default {
|
|||
batteryPower: { type: Number, default: 0 },
|
||||
batterySoc: { type: Number, default: 0 },
|
||||
batteryDischargeControl: { type: Boolean },
|
||||
batteryGridChargeLimit: { type: Number },
|
||||
batteryGridChargeActive: { type: Boolean },
|
||||
batteryMode: { type: String },
|
||||
tariffGrid: { type: Number },
|
||||
tariffFeedIn: { type: Number },
|
||||
|
|
@ -307,6 +319,26 @@ export default {
|
|||
pvPossible() {
|
||||
return this.pvConfigured || this.gridConfigured;
|
||||
},
|
||||
batteryGridChargeText() {
|
||||
return this.$t(
|
||||
`main.energyflow.${this.co2Available ? "clean" : "cheap"}BatteryGridCharge`
|
||||
);
|
||||
},
|
||||
batteryGridChargeNow() {
|
||||
if (this.co2Available) {
|
||||
return this.fmtCo2Short(this.tariffCo2);
|
||||
}
|
||||
return this.fmtPricePerKWh(this.tariffGrid, this.currency, true);
|
||||
},
|
||||
batteryGridChargeLimitFmt() {
|
||||
if (this.batteryGridChargeLimit === null || this.batteryGridChargeLimit === undefined) {
|
||||
return;
|
||||
}
|
||||
if (this.co2Available) {
|
||||
return this.fmtCo2Short(this.batteryGridChargeLimit);
|
||||
}
|
||||
return this.fmtPricePerKWh(this.batteryGridChargeLimit, this.currency, true);
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
pvConfigured() {
|
||||
|
|
|
|||
|
|
@ -1,29 +1,34 @@
|
|||
<template>
|
||||
<div class="d-flex justify-content-between mb-2 entry" :class="{ 'evcc-gray': !active }">
|
||||
<span class="d-flex flex-nowrap">
|
||||
<BatteryIcon v-if="isBattery" :soc="soc" />
|
||||
<VehicleIcon v-else-if="isVehicle" :names="vehicleIcons" />
|
||||
<component :is="`shopicon-regular-${icon}`" v-else></component>
|
||||
</span>
|
||||
<span class="text-nowrap flex-grow-1 ms-3 text-truncate">
|
||||
{{ name }}
|
||||
</span>
|
||||
<span class="text-end text-nowrap ps-1 fw-bold d-flex">
|
||||
<div
|
||||
ref="details"
|
||||
class="fw-normal"
|
||||
:class="{ 'text-decoration-underline': detailsClickable }"
|
||||
data-testid="energyflow-entry-details"
|
||||
data-bs-toggle="tooltip"
|
||||
:tabindex="detailsClickable ? 0 : undefined"
|
||||
@click="detailsClicked"
|
||||
>
|
||||
<AnimatedNumber v-if="!isNaN(details)" :to="details" :format="detailsFmt" />
|
||||
<div class="mb-2 entry" :class="{ 'evcc-gray': !active }">
|
||||
<div class="d-flex justify-content-between">
|
||||
<span class="d-flex flex-nowrap">
|
||||
<BatteryIcon v-if="isBattery" :soc="soc" />
|
||||
<VehicleIcon v-else-if="isVehicle" :names="vehicleIcons" />
|
||||
<component :is="`shopicon-regular-${icon}`" v-else></component>
|
||||
</span>
|
||||
<div class="d-block text-nowrap flex-grow-1 ms-3 text-truncate">
|
||||
{{ name }}
|
||||
</div>
|
||||
<div ref="power" class="power" data-bs-toggle="tooltip" @click="powerClicked">
|
||||
<AnimatedNumber ref="powerNumber" :to="power" :format="kw" />
|
||||
</div>
|
||||
</span>
|
||||
<span class="text-end text-nowrap ps-1 fw-bold d-flex">
|
||||
<div
|
||||
ref="details"
|
||||
class="fw-normal"
|
||||
:class="{ 'text-decoration-underline': detailsClickable }"
|
||||
data-testid="energyflow-entry-details"
|
||||
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="powerClicked">
|
||||
<AnimatedNumber ref="powerNumber" :to="power" :format="kw" />
|
||||
</div>
|
||||
</span>
|
||||
</div>
|
||||
<div v-if="$slots.subline" class="ms-4 ps-3">
|
||||
<slot name="subline" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
|
|
|||
|
|
@ -30,9 +30,9 @@
|
|||
v-bind="smartCostLimitProps"
|
||||
class="mt-2"
|
||||
/>
|
||||
<h4 class="d-flex align-items-center mb-3 mt-5 text-evcc">
|
||||
<h6>
|
||||
{{ $t("main.loadpointSettings.currents") }}
|
||||
</h4>
|
||||
</h6>
|
||||
<div v-if="phasesOptions.length" class="mb-3 row">
|
||||
<label
|
||||
:for="formId(`phases_${phasesOptions[0]}`)"
|
||||
|
|
|
|||
|
|
@ -72,7 +72,8 @@ export default {
|
|||
batteryPower: Number,
|
||||
batterySoc: Number,
|
||||
batteryDischargeControl: Boolean,
|
||||
batterySmartCostLimit: Number,
|
||||
batteryGridChargeLimit: Number,
|
||||
batteryGridChargeActive: Boolean,
|
||||
batteryMode: String,
|
||||
battery: Array,
|
||||
gridCurrents: Array,
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
<template>
|
||||
<div>
|
||||
<h4 class="text-evcc mb-3">{{ title }}</h4>
|
||||
<h6 class="mt-0" v-if="isLoadpoint">{{ title }}</h6>
|
||||
<p>
|
||||
{{ description }}
|
||||
</p>
|
||||
|
|
@ -15,7 +15,7 @@
|
|||
class="form-select form-select-sm mb-1"
|
||||
@change="changeSmartCostLimit"
|
||||
>
|
||||
<option value="0">{{ $t("smartCost.none") }}</option>
|
||||
<option value="null">{{ $t("smartCost.none") }}</option>
|
||||
<option v-for="{ value, name } in costOptions" :key="value" :value="value">
|
||||
{{ name }}
|
||||
</option>
|
||||
|
|
@ -94,7 +94,7 @@ export default {
|
|||
},
|
||||
data: function () {
|
||||
return {
|
||||
selectedSmartCostLimit: 0,
|
||||
selectedSmartCostLimit: null,
|
||||
tariff: null,
|
||||
startTime: null,
|
||||
activeIndex: null,
|
||||
|
|
@ -110,9 +110,7 @@ export default {
|
|||
const stepSize = this.optionStepSize;
|
||||
for (let i = 1; i <= 100; i++) {
|
||||
const value = this.optionStartValue + stepSize * i;
|
||||
if (value != 0) {
|
||||
values.push(this.roundLimit(value));
|
||||
}
|
||||
values.push(this.roundLimit(value));
|
||||
}
|
||||
// add special entry if currently selected value is not in the scale
|
||||
const selected = this.selectedSmartCostLimit;
|
||||
|
|
@ -172,7 +170,7 @@ export default {
|
|||
// TODO: handle multiple matching time slots
|
||||
const price = this.findSlotInRange(start, end, rates)?.price;
|
||||
const charging =
|
||||
price <= this.selectedSmartCostLimit && this.selectedSmartCostLimit !== 0;
|
||||
price <= this.selectedSmartCostLimit && this.selectedSmartCostLimit !== null;
|
||||
const selectable = price !== undefined;
|
||||
result.push({ day, price, startHour, endHour, charging, selectable });
|
||||
}
|
||||
|
|
@ -221,6 +219,9 @@ export default {
|
|||
formId() {
|
||||
return `smartCostLimit-${this.loadpointId || "battery"}`;
|
||||
},
|
||||
isLoadpoint() {
|
||||
return !!this.loadpointId;
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
tariffGrid() {
|
||||
|
|
@ -236,7 +237,7 @@ export default {
|
|||
},
|
||||
methods: {
|
||||
roundLimit(limit) {
|
||||
return limit ? Math.round(limit * 1000) / 1000 : 0;
|
||||
return limit === null ? null : Math.round(limit * 1000) / 1000;
|
||||
},
|
||||
updateTariff: async function () {
|
||||
try {
|
||||
|
|
@ -288,10 +289,6 @@ export default {
|
|||
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;
|
||||
|
|
@ -308,13 +305,18 @@ export default {
|
|||
this.saveSmartCostLimit($event.target.value);
|
||||
},
|
||||
async saveSmartCostLimit(limit) {
|
||||
const isLoadpoint = !!this.loadpointId;
|
||||
const url = isLoadpoint
|
||||
const url = this.isLoadpoint
|
||||
? `loadpoints/${this.loadpointId}/smartcostlimit`
|
||||
: "batterysmartcostlimit"; // currently not implemented
|
||||
: "batterygridchargelimit";
|
||||
|
||||
// delete
|
||||
try {
|
||||
await api.post(`${url}/${encodeURIComponent(limit)}`);
|
||||
if (isLoadpoint && this.multipleLoadpoints) {
|
||||
if (limit === "null") {
|
||||
await api.delete(url);
|
||||
} else {
|
||||
await api.post(`${url}/${encodeURIComponent(limit)}`);
|
||||
}
|
||||
if (this.isLoadpoint && this.multipleLoadpoints) {
|
||||
this.applyToAllVisible = true;
|
||||
}
|
||||
} catch (err) {
|
||||
|
|
@ -323,7 +325,13 @@ export default {
|
|||
},
|
||||
async applyToAll() {
|
||||
try {
|
||||
await api.post(`smartcostlimit/${encodeURIComponent(this.selectedSmartCostLimit)}`);
|
||||
if (this.selectedSmartCostLimit === null) {
|
||||
await api.delete("smartcostlimit");
|
||||
} else {
|
||||
await api.post(
|
||||
`smartcostlimit/${encodeURIComponent(this.selectedSmartCostLimit)}`
|
||||
);
|
||||
}
|
||||
this.applyToAllVisible = false;
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
<template>
|
||||
<div v-if="expiresSoon" class="alert alert-warning my-4" role="alert">
|
||||
<i18n-t tag="div" keypath="settings.sponsorToken.expires">
|
||||
<i18n-t tag="div" keypath="settings.sponsorToken.expires" scope="global">
|
||||
<template #inXDays>
|
||||
{{ inXDays }}
|
||||
</template>
|
||||
|
|
|
|||
|
|
@ -13,7 +13,12 @@
|
|||
<div class="form-check-label">
|
||||
<label for="telemetryEnabled">
|
||||
{{ $t("footer.telemetry.optIn") }}
|
||||
<i18n-t v-if="sponsorActive" tag="span" keypath="footer.telemetry.optInMoreDetails">
|
||||
<i18n-t
|
||||
v-if="sponsorActive"
|
||||
tag="span"
|
||||
keypath="footer.telemetry.optInMoreDetails"
|
||||
scope="global"
|
||||
>
|
||||
<a :href="docsLink" target="_blank">
|
||||
{{ $t("footer.telemetry.optInMoreDetailsLink") }}
|
||||
</a>
|
||||
|
|
|
|||
|
|
@ -72,6 +72,11 @@ meters:
|
|||
if (state.batterySoc > 90) state.batterySoc = 10;
|
||||
state.batterySoc;
|
||||
capacity: 13.4
|
||||
batterymode:
|
||||
source: js
|
||||
vm: shared
|
||||
script: |
|
||||
1
|
||||
|
||||
- name: meter_charger_1
|
||||
type: custom
|
||||
|
|
@ -251,7 +256,14 @@ tariffs:
|
|||
currency: EUR # three letter ISO-4217 currency code (default EUR)
|
||||
grid:
|
||||
type: fixed
|
||||
price: 0.399 # EUR/kWh
|
||||
price: 0.2 # EUR/kWh
|
||||
zones:
|
||||
- hours: 5-7
|
||||
price: 0.6
|
||||
- hours: 11-14
|
||||
price: 0.05
|
||||
- hours: 17-21
|
||||
price: 0.5
|
||||
feedin:
|
||||
type: fixed
|
||||
price: 0.08 # EUR/kWh
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
package core
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/cenkalti/backoff/v4"
|
||||
|
|
@ -51,3 +52,19 @@ func sitePower(log *util.Logger, maxGrid, grid, battery, residual float64) float
|
|||
|
||||
return grid + battery + residual
|
||||
}
|
||||
|
||||
// printPtr returns a string representation of a pointer value
|
||||
func printPtr[T any](format string, v *T) string {
|
||||
if v == nil {
|
||||
return "<nil>"
|
||||
}
|
||||
return fmt.Sprintf(format, *v)
|
||||
}
|
||||
|
||||
func ptrValueEqual[T comparable](a, b *T) bool {
|
||||
if (a == nil) != (b == nil) {
|
||||
return false
|
||||
}
|
||||
|
||||
return a == nil && b == nil || (*a) == (*b)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -39,6 +39,8 @@ const (
|
|||
// battery settings
|
||||
BatteryCapacity = "batteryCapacity"
|
||||
BatteryDischargeControl = "batteryDischargeControl"
|
||||
BatteryGridChargeLimit = "batteryGridChargeLimit"
|
||||
BatteryGridChargeActive = "batteryGridChargeActive"
|
||||
BufferSoc = "bufferSoc"
|
||||
BufferStartSoc = "bufferStartSoc"
|
||||
MaxGridSupplyWhileBatteryCharging = "maxGridSupplyWhileBatteryCharging"
|
||||
|
|
|
|||
|
|
@ -121,12 +121,12 @@ type Loadpoint struct {
|
|||
MinCurrent_ float64 `mapstructure:"minCurrent"`
|
||||
MaxCurrent_ float64 `mapstructure:"maxCurrent"`
|
||||
|
||||
minCurrent float64 // PV mode: start current Min+PV mode: min current
|
||||
maxCurrent float64 // Max allowed current. Physically ensured by the charger
|
||||
configuredPhases int // Charger configured phase mode 0/1/3
|
||||
limitSoc int // Session limit for soc
|
||||
limitEnergy float64 // Session limit for energy
|
||||
smartCostLimit float64 // always charge if cost is below this value
|
||||
minCurrent float64 // PV mode: start current Min+PV mode: min current
|
||||
maxCurrent float64 // Max allowed current. Physically ensured by the charger
|
||||
configuredPhases int // Charger configured phase mode 0/1/3
|
||||
limitSoc int // Session limit for soc
|
||||
limitEnergy float64 // Session limit for energy
|
||||
smartCostLimit *float64 // always charge if cost is below this value
|
||||
|
||||
mode api.ChargeMode
|
||||
enabled bool // Charger enabled state
|
||||
|
|
@ -337,8 +337,9 @@ func (lp *Loadpoint) restoreSettings() {
|
|||
lp.setLimitEnergy(v)
|
||||
}
|
||||
if v, err := lp.settings.Float(keys.SmartCostLimit); err == nil {
|
||||
lp.SetSmartCostLimit(v)
|
||||
lp.SetSmartCostLimit(&v)
|
||||
}
|
||||
|
||||
t, err1 := lp.settings.Time(keys.PlanTime)
|
||||
v, err2 := lp.settings.Float(keys.PlanEnergy)
|
||||
if err1 == nil && err2 == nil {
|
||||
|
|
@ -1637,9 +1638,18 @@ func (lp *Loadpoint) phaseSwitchCompleted() bool {
|
|||
}
|
||||
|
||||
// Update is the main control function. It reevaluates meters and charger state
|
||||
func (lp *Loadpoint) Update(sitePower float64, smartCostActive bool, smartCostNextStart time.Time, batteryBuffered, batteryStart bool, greenShare float64, effPrice, effCo2 *float64) {
|
||||
func (lp *Loadpoint) Update(sitePower float64, rates api.Rates, batteryBuffered, batteryStart bool, greenShare float64, effPrice, effCo2 *float64) {
|
||||
// smart cost
|
||||
smartCostActive := lp.smartCostActive(rates)
|
||||
lp.publish(keys.SmartCostActive, smartCostActive)
|
||||
|
||||
var smartCostNextStart time.Time
|
||||
if !smartCostActive {
|
||||
smartCostNextStart = lp.smartCostNextStart(rates)
|
||||
}
|
||||
lp.publish(keys.SmartCostNextStart, smartCostNextStart)
|
||||
|
||||
// long-running tasks
|
||||
lp.processTasks()
|
||||
|
||||
// read and publish meters first- charge power and currents have already been updated by the site
|
||||
|
|
|
|||
|
|
@ -110,9 +110,9 @@ type API interface {
|
|||
//
|
||||
|
||||
// GetSmartChargingActive determines if smart charging is active
|
||||
GetSmartCostLimit() float64
|
||||
GetSmartCostLimit() *float64
|
||||
// SetSmartCostLimit sets the smart cost limit
|
||||
SetSmartCostLimit(limit float64)
|
||||
SetSmartCostLimit(limit *float64)
|
||||
|
||||
//
|
||||
// power and energy
|
||||
|
|
|
|||
|
|
@ -380,10 +380,10 @@ func (mr *MockAPIMockRecorder) GetRemainingEnergy() *gomock.Call {
|
|||
}
|
||||
|
||||
// GetSmartCostLimit mocks base method.
|
||||
func (m *MockAPI) GetSmartCostLimit() float64 {
|
||||
func (m *MockAPI) GetSmartCostLimit() *float64 {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "GetSmartCostLimit")
|
||||
ret0, _ := ret[0].(float64)
|
||||
ret0, _ := ret[0].(*float64)
|
||||
return ret0
|
||||
}
|
||||
|
||||
|
|
@ -602,7 +602,7 @@ func (mr *MockAPIMockRecorder) SetPriority(arg0 any) *gomock.Call {
|
|||
}
|
||||
|
||||
// SetSmartCostLimit mocks base method.
|
||||
func (m *MockAPI) SetSmartCostLimit(arg0 float64) {
|
||||
func (m *MockAPI) SetSmartCostLimit(arg0 *float64) {
|
||||
m.ctrl.T.Helper()
|
||||
m.ctrl.Call(m, "SetSmartCostLimit", arg0)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -489,23 +489,29 @@ func (lp *Loadpoint) StartVehicleDetection() {
|
|||
}
|
||||
|
||||
// GetSmartCostLimit gets the smart cost limit
|
||||
func (lp *Loadpoint) GetSmartCostLimit() float64 {
|
||||
func (lp *Loadpoint) GetSmartCostLimit() *float64 {
|
||||
lp.RLock()
|
||||
defer lp.RUnlock()
|
||||
return lp.smartCostLimit
|
||||
}
|
||||
|
||||
// SetSmartCostLimit sets the smart cost limit
|
||||
func (lp *Loadpoint) SetSmartCostLimit(val float64) {
|
||||
func (lp *Loadpoint) SetSmartCostLimit(val *float64) {
|
||||
lp.Lock()
|
||||
defer lp.Unlock()
|
||||
|
||||
lp.log.DEBUG.Println("set smart cost limit:", val)
|
||||
lp.log.DEBUG.Println("set smart cost limit:", printPtr("%.1f", val))
|
||||
|
||||
if lp.smartCostLimit != val {
|
||||
if !ptrValueEqual(lp.smartCostLimit, val) {
|
||||
lp.smartCostLimit = val
|
||||
lp.settings.SetFloat(keys.SmartCostLimit, lp.smartCostLimit)
|
||||
lp.publish(keys.SmartCostLimit, lp.smartCostLimit)
|
||||
|
||||
if val == nil {
|
||||
lp.settings.SetString(keys.SmartCostLimit, "")
|
||||
lp.publish(keys.SmartCostLimit, nil)
|
||||
} else {
|
||||
lp.settings.SetFloat(keys.SmartCostLimit, *val)
|
||||
lp.publish(keys.SmartCostLimit, *val)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
31
core/loadpoint_smartcost.go
Normal file
31
core/loadpoint_smartcost.go
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
package core
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/evcc-io/evcc/api"
|
||||
)
|
||||
|
||||
func (lp *Loadpoint) smartCostActive(rates api.Rates) bool {
|
||||
// potential error has already been logged by site, ignore
|
||||
rate, _ := rates.Current(time.Now())
|
||||
limit := lp.GetSmartCostLimit()
|
||||
return limit != nil && !rate.IsEmpty() && rate.Price <= *limit
|
||||
}
|
||||
|
||||
// smartCostNextStart returns the next start time for a smart cost rate below the limit
|
||||
func (lp *Loadpoint) smartCostNextStart(rates api.Rates) time.Time {
|
||||
limit := lp.GetSmartCostLimit()
|
||||
if limit == nil || rates == nil {
|
||||
return time.Time{}
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
for _, slot := range rates {
|
||||
if slot.Start.After(now) && slot.Price <= *limit {
|
||||
return slot.Start
|
||||
}
|
||||
}
|
||||
|
||||
return time.Time{}
|
||||
}
|
||||
|
|
@ -182,7 +182,7 @@ func TestUpdatePowerZero(t *testing.T) {
|
|||
}
|
||||
|
||||
lp.mode = tc.mode
|
||||
lp.Update(0, false, time.Time{}, false, false, 0, nil, nil) // false,sitePower false,0
|
||||
lp.Update(0, nil, false, false, 0, nil, nil) // false,sitePower false,0
|
||||
|
||||
ctrl.Finish()
|
||||
}
|
||||
|
|
@ -428,7 +428,7 @@ func TestDisableAndEnableAtTargetSoc(t *testing.T) {
|
|||
charger.EXPECT().Status().Return(api.StatusC, nil)
|
||||
charger.EXPECT().Enabled().Return(lp.enabled, nil)
|
||||
charger.EXPECT().MaxCurrent(int64(maxA)).Return(nil)
|
||||
lp.Update(500, false, time.Time{}, false, false, 0, nil, nil)
|
||||
lp.Update(500, nil, false, false, 0, nil, nil)
|
||||
ctrl.Finish()
|
||||
|
||||
t.Log("charging above target - soc deactivates charger")
|
||||
|
|
@ -437,7 +437,7 @@ func TestDisableAndEnableAtTargetSoc(t *testing.T) {
|
|||
charger.EXPECT().Status().Return(api.StatusC, nil)
|
||||
charger.EXPECT().Enabled().Return(lp.enabled, nil)
|
||||
charger.EXPECT().Enable(false).Return(nil)
|
||||
lp.Update(500, false, time.Time{}, false, false, 0, nil, nil)
|
||||
lp.Update(500, nil, false, false, 0, nil, nil)
|
||||
ctrl.Finish()
|
||||
|
||||
t.Log("deactivated charger changes status to B")
|
||||
|
|
@ -445,14 +445,14 @@ func TestDisableAndEnableAtTargetSoc(t *testing.T) {
|
|||
vehicle.EXPECT().Soc().Return(95.0, nil)
|
||||
charger.EXPECT().Status().Return(api.StatusB, nil)
|
||||
charger.EXPECT().Enabled().Return(lp.enabled, nil)
|
||||
lp.Update(-5000, false, time.Time{}, false, false, 0, nil, nil)
|
||||
lp.Update(-500, nil, false, false, 0, nil, nil)
|
||||
ctrl.Finish()
|
||||
|
||||
t.Log("soc has risen below target - soc update prevented by timer")
|
||||
clock.Add(5 * time.Minute)
|
||||
charger.EXPECT().Status().Return(api.StatusB, nil)
|
||||
charger.EXPECT().Enabled().Return(lp.enabled, nil)
|
||||
lp.Update(-5000, false, time.Time{}, false, false, 0, nil, nil)
|
||||
lp.Update(-500, nil, false, false, 0, nil, nil)
|
||||
ctrl.Finish()
|
||||
|
||||
t.Log("soc has fallen below target - soc update timer expired")
|
||||
|
|
@ -462,7 +462,7 @@ func TestDisableAndEnableAtTargetSoc(t *testing.T) {
|
|||
charger.EXPECT().Enabled().Return(lp.enabled, nil)
|
||||
charger.EXPECT().MaxCurrent(int64(maxA)).Return(nil)
|
||||
charger.EXPECT().Enable(true).Return(nil)
|
||||
lp.Update(-5000, false, time.Time{}, false, false, 0, nil, nil)
|
||||
lp.Update(-500, nil, false, false, 0, nil, nil)
|
||||
ctrl.Finish()
|
||||
}
|
||||
|
||||
|
|
@ -497,14 +497,14 @@ func TestSetModeAndSocAtDisconnect(t *testing.T) {
|
|||
charger.EXPECT().Enabled().Return(lp.enabled, nil)
|
||||
charger.EXPECT().Status().Return(api.StatusC, nil)
|
||||
charger.EXPECT().MaxCurrent(int64(maxA)).Return(nil)
|
||||
lp.Update(500, false, time.Time{}, false, false, 0, nil, nil)
|
||||
lp.Update(500, nil, false, false, 0, nil, nil)
|
||||
|
||||
t.Log("switch off when disconnected")
|
||||
clock.Add(5 * time.Minute)
|
||||
charger.EXPECT().Enabled().Return(lp.enabled, nil)
|
||||
charger.EXPECT().Status().Return(api.StatusA, nil)
|
||||
charger.EXPECT().Enable(false).Return(nil)
|
||||
lp.Update(-3000, false, time.Time{}, false, false, 0, nil, nil)
|
||||
lp.Update(-300, nil, false, false, 0, nil, nil)
|
||||
|
||||
if mode := lp.GetMode(); mode != api.ModeOff {
|
||||
t.Error("unexpected mode", mode)
|
||||
|
|
@ -567,14 +567,14 @@ func TestChargedEnergyAtDisconnect(t *testing.T) {
|
|||
rater.EXPECT().ChargedEnergy().Return(0.0, nil)
|
||||
charger.EXPECT().Enabled().Return(lp.enabled, nil)
|
||||
charger.EXPECT().Status().Return(api.StatusC, nil)
|
||||
lp.Update(-1, false, time.Time{}, false, false, 0, nil, nil)
|
||||
lp.Update(-1, nil, false, false, 0, nil, nil)
|
||||
|
||||
t.Log("at 1:00h charging at 5 kWh")
|
||||
clock.Add(time.Hour)
|
||||
rater.EXPECT().ChargedEnergy().Return(5.0, nil)
|
||||
charger.EXPECT().Enabled().Return(lp.enabled, nil)
|
||||
charger.EXPECT().Status().Return(api.StatusC, nil)
|
||||
lp.Update(-1, false, time.Time{}, false, false, 0, nil, nil)
|
||||
lp.Update(-1, nil, false, false, 0, nil, nil)
|
||||
expectCache("chargedEnergy", 5000.0)
|
||||
|
||||
t.Log("at 1:00h stop charging at 5 kWh")
|
||||
|
|
@ -582,7 +582,7 @@ func TestChargedEnergyAtDisconnect(t *testing.T) {
|
|||
rater.EXPECT().ChargedEnergy().Return(5.0, nil)
|
||||
charger.EXPECT().Enabled().Return(lp.enabled, nil)
|
||||
charger.EXPECT().Status().Return(api.StatusB, nil)
|
||||
lp.Update(-1, false, time.Time{}, false, false, 0, nil, nil)
|
||||
lp.Update(-1, nil, false, false, 0, nil, nil)
|
||||
expectCache("chargedEnergy", 5000.0)
|
||||
|
||||
t.Log("at 1:00h restart charging at 5 kWh")
|
||||
|
|
@ -590,7 +590,7 @@ func TestChargedEnergyAtDisconnect(t *testing.T) {
|
|||
rater.EXPECT().ChargedEnergy().Return(5.0, nil)
|
||||
charger.EXPECT().Enabled().Return(lp.enabled, nil)
|
||||
charger.EXPECT().Status().Return(api.StatusC, nil)
|
||||
lp.Update(-1, false, time.Time{}, false, false, 0, nil, nil)
|
||||
lp.Update(-1, nil, false, false, 0, nil, nil)
|
||||
expectCache("chargedEnergy", 5000.0)
|
||||
|
||||
t.Log("at 1:30h continue charging at 7.5 kWh")
|
||||
|
|
@ -598,7 +598,7 @@ func TestChargedEnergyAtDisconnect(t *testing.T) {
|
|||
rater.EXPECT().ChargedEnergy().Return(7.5, nil)
|
||||
charger.EXPECT().Enabled().Return(lp.enabled, nil)
|
||||
charger.EXPECT().Status().Return(api.StatusC, nil)
|
||||
lp.Update(-1, false, time.Time{}, false, false, 0, nil, nil)
|
||||
lp.Update(-1, nil, false, false, 0, nil, nil)
|
||||
expectCache("chargedEnergy", 7500.0)
|
||||
|
||||
t.Log("at 2:00h stop charging at 10 kWh")
|
||||
|
|
@ -606,7 +606,7 @@ func TestChargedEnergyAtDisconnect(t *testing.T) {
|
|||
rater.EXPECT().ChargedEnergy().Return(10.0, nil)
|
||||
charger.EXPECT().Enabled().Return(lp.enabled, nil)
|
||||
charger.EXPECT().Status().Return(api.StatusB, nil)
|
||||
lp.Update(-1, false, time.Time{}, false, false, 0, nil, nil)
|
||||
lp.Update(-1, nil, false, false, 0, nil, nil)
|
||||
expectCache("chargedEnergy", 10000.0)
|
||||
|
||||
ctrl.Finish()
|
||||
|
|
|
|||
|
|
@ -270,7 +270,7 @@ func TestReconnectVehicle(t *testing.T) {
|
|||
// vehicle not updated yet
|
||||
vehicle.MockChargeState.EXPECT().Status().Return(api.StatusA, nil)
|
||||
|
||||
lp.Update(0, false, time.Time{}, false, false, 0, nil, nil)
|
||||
lp.Update(0, nil, false, false, 0, nil, nil)
|
||||
ctrl.Finish()
|
||||
|
||||
// detection started
|
||||
|
|
@ -284,7 +284,7 @@ func TestReconnectVehicle(t *testing.T) {
|
|||
// vehicle not updated yet
|
||||
vehicle.MockChargeState.EXPECT().Status().Return(api.StatusB, nil)
|
||||
|
||||
lp.Update(0, false, time.Time{}, false, false, 0, nil, nil)
|
||||
lp.Update(0, nil, false, false, 0, nil, nil)
|
||||
ctrl.Finish()
|
||||
|
||||
// vehicle detected
|
||||
|
|
|
|||
|
|
@ -52,7 +52,7 @@ func AverageCost(plan api.Rates) float64 {
|
|||
// SlotAt returns the slot for the given time or an empty slot
|
||||
func SlotAt(time time.Time, plan api.Rates) api.Rate {
|
||||
for _, slot := range plan {
|
||||
if (slot.Start.Before(time) || slot.Start.Equal(time)) && slot.End.After(time) {
|
||||
if !slot.Start.After(time) && slot.End.After(time) {
|
||||
return slot
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -89,12 +89,12 @@ func (t *Planner) continuousPlan(rates api.Rates, start, end time.Time) api.Rate
|
|||
res := make(api.Rates, 0, len(rates)+2)
|
||||
for _, r := range rates {
|
||||
// slot before continuous plan
|
||||
if r.End.Before(start) || r.End.Equal(start) {
|
||||
if !r.End.After(start) {
|
||||
continue
|
||||
}
|
||||
|
||||
// slot after continuous plan
|
||||
if r.Start.After(end) || r.Start.Equal(end) {
|
||||
if !r.Start.Before(end) {
|
||||
continue
|
||||
}
|
||||
|
||||
|
|
|
|||
47
core/site.go
47
core/site.go
|
|
@ -37,7 +37,7 @@ const standbyPower = 10 // consider less than 10W as charger in standby
|
|||
// updater abstracts the Loadpoint implementation for testing
|
||||
type updater interface {
|
||||
loadpoint.API
|
||||
Update(availablePower float64, smartCostActive bool, smartCostNextStart time.Time, batteryBuffered, batteryStart bool, greenShare float64, effectivePrice, effectiveCo2 *float64)
|
||||
Update(availablePower float64, rates api.Rates, batteryBuffered, batteryStart bool, greenShare float64, effectivePrice, effectiveCo2 *float64)
|
||||
}
|
||||
|
||||
// meterMeasurement is used as slice element for publishing structured data
|
||||
|
|
@ -85,10 +85,11 @@ type Site struct {
|
|||
auxMeters []api.Meter // Auxiliary meters
|
||||
|
||||
// battery settings
|
||||
prioritySoc float64 // prefer battery up to this Soc
|
||||
bufferSoc float64 // continue charging on battery above this Soc
|
||||
bufferStartSoc float64 // start charging on battery above this Soc
|
||||
batteryDischargeControl bool // prevent battery discharge for fast and planned charging
|
||||
prioritySoc float64 // prefer battery up to this Soc
|
||||
bufferSoc float64 // continue charging on battery above this Soc
|
||||
bufferStartSoc float64 // start charging on battery above this Soc
|
||||
batteryDischargeControl bool // prevent battery discharge for fast and planned charging
|
||||
batteryGridChargeLimit *float64 // grid charging limit
|
||||
|
||||
loadpoints []*Loadpoint // Loadpoints
|
||||
tariffs *tariff.Tariffs // Tariffs
|
||||
|
|
@ -298,6 +299,10 @@ func (site *Site) restoreSettings() error {
|
|||
return err
|
||||
}
|
||||
}
|
||||
if v, err := settings.Float(keys.BatteryGridChargeLimit); err == nil {
|
||||
site.SetBatteryGridChargeLimit(&v)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
|
|
@ -792,19 +797,25 @@ func (site *Site) update(lp updater) {
|
|||
flexiblePower = site.prioritizer.GetChargePowerFlexibility(lp)
|
||||
}
|
||||
|
||||
var smartCostActive bool
|
||||
if rate, err := site.plannerRate(); err == nil {
|
||||
smartCostActive = site.smartCostActive(lp, rate)
|
||||
} else {
|
||||
site.log.WARN.Println("smartCostActive:", err)
|
||||
// battery mode handling
|
||||
rates, err := site.plannerRates()
|
||||
if err != nil {
|
||||
site.log.WARN.Println("planner:", err)
|
||||
}
|
||||
|
||||
var smartCostNextStart time.Time
|
||||
if !smartCostActive {
|
||||
if rates, err := site.plannerRates(); err == nil {
|
||||
smartCostNextStart = site.smartCostNextStart(lp, rates)
|
||||
rate, err := rates.Current(time.Now())
|
||||
if rates != nil && err != nil {
|
||||
site.log.WARN.Println("planner:", err)
|
||||
}
|
||||
|
||||
batteryGridChargeActive := site.batteryGridChargeActive(rate)
|
||||
site.publish(keys.BatteryGridChargeActive, batteryGridChargeActive)
|
||||
|
||||
if batteryMode := site.requiredBatteryMode(batteryGridChargeActive, rate); batteryMode != api.BatteryUnknown {
|
||||
if err := site.applyBatteryMode(batteryMode); err == nil {
|
||||
site.SetBatteryMode(batteryMode)
|
||||
} else {
|
||||
site.log.WARN.Println("smartCostNextStart:", err)
|
||||
site.log.ERROR.Println("battery mode:", err)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -820,7 +831,7 @@ func (site *Site) update(lp updater) {
|
|||
greenShareHome := site.greenShare(0, homePower)
|
||||
greenShareLoadpoints := site.greenShare(nonChargePower, nonChargePower+totalChargePower)
|
||||
|
||||
lp.Update(sitePower, smartCostActive, smartCostNextStart, batteryBuffered, batteryStart, greenShareLoadpoints, site.effectivePrice(greenShareLoadpoints), site.effectiveCo2(greenShareLoadpoints))
|
||||
lp.Update(sitePower, rates, batteryBuffered, batteryStart, greenShareLoadpoints, site.effectivePrice(greenShareLoadpoints), site.effectiveCo2(greenShareLoadpoints))
|
||||
|
||||
site.Health.Update()
|
||||
|
||||
|
|
@ -833,10 +844,6 @@ func (site *Site) update(lp updater) {
|
|||
site.log.ERROR.Println(err)
|
||||
}
|
||||
|
||||
if site.GetBatteryDischargeControl() {
|
||||
site.updateBatteryMode()
|
||||
}
|
||||
|
||||
site.stats.Update(site)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -40,6 +40,11 @@ type API interface {
|
|||
GetMaxGridSupplyWhileBatteryCharging() float64
|
||||
SetMaxGridSupplyWhileBatteryCharging(float64) error
|
||||
|
||||
// GetBatteryGridChargeLimit get the grid charge limit
|
||||
GetBatteryGridChargeLimit() *float64
|
||||
// SetBatteryGridChargeLimit sets the grid charge limit
|
||||
SetBatteryGridChargeLimit(limit *float64)
|
||||
|
||||
//
|
||||
// power and energy
|
||||
//
|
||||
|
|
|
|||
|
|
@ -347,13 +347,6 @@ func (site *Site) SetBatteryDischargeControl(val bool) error {
|
|||
defer site.Unlock()
|
||||
|
||||
if site.batteryDischargeControl != val {
|
||||
// reset to normal when disabling
|
||||
if mode := site.batteryMode; !val && batteryModeModified(mode) {
|
||||
if err := site.applyBatteryMode(api.BatteryNormal); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
site.batteryDischargeControl = val
|
||||
settings.SetBool(keys.BatteryDischargeControl, val)
|
||||
site.publish(keys.BatteryDischargeControl, val)
|
||||
|
|
@ -361,3 +354,28 @@ func (site *Site) SetBatteryDischargeControl(val bool) error {
|
|||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (site *Site) GetBatteryGridChargeLimit() *float64 {
|
||||
site.RLock()
|
||||
defer site.RUnlock()
|
||||
return site.batteryGridChargeLimit
|
||||
}
|
||||
|
||||
func (site *Site) SetBatteryGridChargeLimit(val *float64) {
|
||||
site.log.DEBUG.Println("set grid charge limit:", printPtr("%.1f", val))
|
||||
|
||||
site.Lock()
|
||||
defer site.Unlock()
|
||||
|
||||
if !ptrValueEqual(site.batteryGridChargeLimit, val) {
|
||||
site.batteryGridChargeLimit = val
|
||||
|
||||
if val == nil {
|
||||
settings.SetString(keys.BatteryGridChargeLimit, "")
|
||||
site.publish(keys.BatteryGridChargeLimit, nil)
|
||||
} else {
|
||||
settings.SetFloat(keys.BatteryGridChargeLimit, *val)
|
||||
site.publish(keys.BatteryGridChargeLimit, *val)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,8 +1,6 @@
|
|||
package core
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/evcc-io/evcc/api"
|
||||
"github.com/evcc-io/evcc/core/keys"
|
||||
"github.com/evcc-io/evcc/core/loadpoint"
|
||||
|
|
@ -37,10 +35,25 @@ func (site *Site) SetBatteryMode(batMode api.BatteryMode) {
|
|||
}
|
||||
}
|
||||
|
||||
// applyBatteryMode applies the mode to each battery and updates
|
||||
// internal state if successful (requires lock)
|
||||
// requiredBatteryMode determines required battery mode based on grid charge and rate
|
||||
func (site *Site) requiredBatteryMode(batteryGridChargeActive bool, rate api.Rate) api.BatteryMode {
|
||||
var res api.BatteryMode
|
||||
batMode := site.GetBatteryMode()
|
||||
|
||||
switch {
|
||||
case batteryGridChargeActive && batMode != api.BatteryCharge:
|
||||
res = api.BatteryCharge
|
||||
case !batteryGridChargeActive && site.dischargeControlActive(rate) && batMode != api.BatteryHold:
|
||||
res = api.BatteryHold
|
||||
case batteryModeModified(batMode):
|
||||
res = api.BatteryNormal
|
||||
}
|
||||
|
||||
return res
|
||||
}
|
||||
|
||||
// applyBatteryMode applies the mode to each battery
|
||||
func (site *Site) applyBatteryMode(mode api.BatteryMode) error {
|
||||
// update batteries
|
||||
for _, meter := range site.batteryMeters {
|
||||
if batCtrl, ok := meter.(api.BatteryController); ok {
|
||||
if err := batCtrl.SetBatteryMode(mode); err != nil {
|
||||
|
|
@ -49,9 +62,6 @@ func (site *Site) applyBatteryMode(mode api.BatteryMode) error {
|
|||
}
|
||||
}
|
||||
|
||||
// update state and publish
|
||||
site.setBatteryMode(mode)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
|
|
@ -64,62 +74,27 @@ func (site *Site) plannerRates() (api.Rates, error) {
|
|||
return tariff.Rates()
|
||||
}
|
||||
|
||||
func (site *Site) plannerRate() (*api.Rate, error) {
|
||||
rates, err := site.plannerRates()
|
||||
if rates == nil || err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
rate, err := rates.Current(time.Now())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &rate, nil
|
||||
}
|
||||
|
||||
func (site *Site) smartCostActive(lp loadpoint.API, rate *api.Rate) bool {
|
||||
func (site *Site) smartCostActive(lp loadpoint.API, rate api.Rate) bool {
|
||||
limit := lp.GetSmartCostLimit()
|
||||
return limit != 0 && rate != nil && rate.Price <= limit
|
||||
return limit != nil && !rate.IsEmpty() && rate.Price <= *limit
|
||||
}
|
||||
|
||||
func (site *Site) smartCostNextStart(lp loadpoint.API, rate api.Rates) time.Time {
|
||||
limit := lp.GetSmartCostLimit()
|
||||
if limit == 0 || rate == nil {
|
||||
return time.Time{}
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
for _, slot := range rate {
|
||||
if slot.Start.After(now) && slot.Price <= limit {
|
||||
return slot.Start
|
||||
}
|
||||
}
|
||||
|
||||
return time.Time{}
|
||||
func (site *Site) batteryGridChargeActive(rate api.Rate) bool {
|
||||
limit := site.GetBatteryGridChargeLimit()
|
||||
return limit != nil && !rate.IsEmpty() && rate.Price <= *limit
|
||||
}
|
||||
|
||||
func (site *Site) updateBatteryMode() {
|
||||
mode := api.BatteryNormal
|
||||
|
||||
rate, err := site.plannerRate()
|
||||
if err != nil {
|
||||
site.log.WARN.Println("smart cost:", err)
|
||||
func (site *Site) dischargeControlActive(rate api.Rate) bool {
|
||||
if !site.GetBatteryDischargeControl() {
|
||||
return false
|
||||
}
|
||||
|
||||
for _, lp := range site.Loadpoints() {
|
||||
smartCostActive := site.smartCostActive(lp, rate)
|
||||
if lp.GetStatus() == api.StatusC && (smartCostActive || lp.IsFastChargingActive()) {
|
||||
mode = api.BatteryHold
|
||||
break
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
if batMode := site.GetBatteryMode(); mode != batMode {
|
||||
site.Lock()
|
||||
if err := site.applyBatteryMode(mode); err != nil {
|
||||
site.log.ERROR.Println("battery mode:", err)
|
||||
}
|
||||
site.Unlock()
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,36 +0,0 @@
|
|||
package core
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/evcc-io/evcc/api"
|
||||
"github.com/evcc-io/evcc/util"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"go.uber.org/mock/gomock"
|
||||
)
|
||||
|
||||
func TestUpdateBatteryMode(t *testing.T) {
|
||||
expBatMode := api.BatteryHold
|
||||
|
||||
ctrl := gomock.NewController(t)
|
||||
|
||||
batCtrl := struct {
|
||||
*api.MockBatteryController
|
||||
*api.MockMeter
|
||||
}{
|
||||
api.NewMockBatteryController(ctrl),
|
||||
api.NewMockMeter(ctrl),
|
||||
}
|
||||
batCtrl.MockBatteryController.EXPECT().SetBatteryMode(expBatMode).Times(1)
|
||||
|
||||
s := &Site{
|
||||
log: util.NewLogger("foo"),
|
||||
batteryMeters: []api.Meter{batCtrl},
|
||||
batteryMode: api.BatteryNormal,
|
||||
}
|
||||
|
||||
err := s.applyBatteryMode(expBatMode)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, expBatMode, s.GetBatteryMode())
|
||||
}
|
||||
25
i18n/de.toml
25
i18n/de.toml
|
|
@ -5,20 +5,21 @@ control = "Batteriesteuerung"
|
|||
discharge = "Verhindere Entladung im Schnell-Modus und bei geplantem Laden."
|
||||
disclaimerHint = "Hinweis:"
|
||||
disclaimerText = "Nur relevant im PV-Modus. Das Ladeverhalten wird entsprechend angepasst."
|
||||
legendBottomName = "Haus hat Priorität"
|
||||
legendBottomSubline = "nicht zum Laden verwenden"
|
||||
legendMiddleName = "Fahrzeug zuerst"
|
||||
legendMiddleSubline = "dann das Haus"
|
||||
legendTitle = "Wie soll Sonnenstrom genutzt werden?"
|
||||
legendTopAutostart = "startet automatisch"
|
||||
legendTopName = "Batterieunterstütztes Laden"
|
||||
legendTopSubline = "ohne Unterbrechungen"
|
||||
gridChargeTab = "Netzladen"
|
||||
legendBottomName = "Priorisiere die Hausbatterie"
|
||||
legendBottomSubline = "bis sie {soc} erreicht hat."
|
||||
legendMiddleName = "Priorisiere Fahrzeugladen,"
|
||||
legendMiddleSubline = "wenn Hausbatterie über {soc} ist."
|
||||
legendTopAutostart = "Starte automatisch,"
|
||||
legendTopName = "Batterieunterstütztes Fahrzeugladen,"
|
||||
legendTopSubline = "wenn Hausbatterie über {soc} ist."
|
||||
modalTitle = "Hausbatterie"
|
||||
usageTab = "Batterienutzung"
|
||||
|
||||
[batterySettings.bufferStart]
|
||||
above = "wenn über {soc}"
|
||||
full = "wenn auf {soc}"
|
||||
never = "nur mit genug PV-Überschuss"
|
||||
above = "wenn über {soc}."
|
||||
full = "wenn auf {soc}."
|
||||
never = "nur mit genug PV-Überschuss."
|
||||
|
||||
[config]
|
||||
|
||||
|
|
@ -389,6 +390,8 @@ batteryCharge = "Batterie laden"
|
|||
batteryDischarge = "Batterie entladen"
|
||||
batteryHold = "Batterie (gesperrt)"
|
||||
batteryTooltip = "{energy} von {total} ({soc})"
|
||||
cheapBatteryGridCharge = "günstige Netzenergie"
|
||||
cleanBatteryGridCharge = "saubere Netzenergie"
|
||||
gridImport = "Netzbezug"
|
||||
homePower = "Verbrauch"
|
||||
loadpoints = "Ladepunkt | Ladepunkt | {count} Ladepunkte"
|
||||
|
|
|
|||
25
i18n/en.toml
25
i18n/en.toml
|
|
@ -5,20 +5,21 @@ control = "Battery control"
|
|||
discharge = "Prevent discharge in fast mode and planned charging."
|
||||
disclaimerHint = "Note:"
|
||||
disclaimerText = "These settings only affect solar mode. Charging behaviour is adjusted accordingly."
|
||||
legendBottomName = "home priority"
|
||||
legendBottomSubline = "not used for charging"
|
||||
legendMiddleName = "vehicle first"
|
||||
legendMiddleSubline = "home second"
|
||||
legendTitle = "How should solar energy be used?"
|
||||
legendTopAutostart = "starts automatically"
|
||||
legendTopName = "battery supported charging"
|
||||
legendTopSubline = "without interruptions"
|
||||
gridChargeTab = "Grid charging"
|
||||
legendBottomName = "Prioritize home battery charging"
|
||||
legendBottomSubline = "until it reaches {soc}."
|
||||
legendMiddleName = "Prioritize vehicle charging"
|
||||
legendMiddleSubline = "when home battery is above {soc}."
|
||||
legendTopAutostart = "Start automatically"
|
||||
legendTopName = "Battery-supported vehicle charging"
|
||||
legendTopSubline = "when home battery is above {soc}."
|
||||
modalTitle = "Home Battery"
|
||||
usageTab = "Battery usage"
|
||||
|
||||
[batterySettings.bufferStart]
|
||||
above = "when above {soc}"
|
||||
full = "when at {soc}"
|
||||
never = "only with enough surplus"
|
||||
above = "when above {soc}."
|
||||
full = "when at {soc}."
|
||||
never = "only with enough surplus."
|
||||
|
||||
[config]
|
||||
|
||||
|
|
@ -391,6 +392,8 @@ batteryCharge = "Battery charging"
|
|||
batteryDischarge = "Battery discharging"
|
||||
batteryHold = "Battery (locked)"
|
||||
batteryTooltip = "{energy} of {total} ({soc})"
|
||||
cheapBatteryGridCharge = "cheap grid energy"
|
||||
cleanBatteryGridCharge = "clean grid energy"
|
||||
gridImport = "Grid use"
|
||||
homePower = "Consumption"
|
||||
loadpoints = "Charger| Charger | {count} chargers"
|
||||
|
|
|
|||
|
|
@ -105,10 +105,13 @@ func (s *HTTPd) RegisterSiteHandlers(site site.API, valueChan chan<- util.Param)
|
|||
"buffersoc": {"POST", "/buffersoc/{value:[0-9.]+}", floatHandler(site.SetBufferSoc, site.GetBufferSoc)},
|
||||
"bufferstartsoc": {"POST", "/bufferstartsoc/{value:[0-9.]+}", floatHandler(site.SetBufferStartSoc, site.GetBufferStartSoc)},
|
||||
"batterydischargecontrol": {"POST", "/batterydischargecontrol/{value:[a-z]+}", boolHandler(site.SetBatteryDischargeControl, site.GetBatteryDischargeControl)},
|
||||
"batterygridcharge": {"POST", "/batterygridchargelimit/{value:-?[0-9.]+}", floatPtrHandler(pass(site.SetBatteryGridChargeLimit), site.GetBatteryGridChargeLimit)},
|
||||
"batterygridchargedelete": {"DELETE", "/batterygridchargelimit", floatPtrHandler(pass(site.SetBatteryGridChargeLimit), site.GetBatteryGridChargeLimit)},
|
||||
"maxgridsupply": {"POST", "/maxgridsupply/{value:[0-9.]+}", floatHandler(site.SetMaxGridSupplyWhileBatteryCharging, site.GetMaxGridSupplyWhileBatteryCharging)},
|
||||
"prioritysoc": {"POST", "/prioritysoc/{value:[0-9.]+}", floatHandler(site.SetPrioritySoc, site.GetPrioritySoc)},
|
||||
"residualpower": {"POST", "/residualpower/{value:-?[0-9.]+}", floatHandler(site.SetResidualPower, site.GetResidualPower)},
|
||||
"smartcost": {"POST", "/smartcostlimit/{value:-?[0-9.]+}", updateSmartCostLimit(site)},
|
||||
"smartcostdelete": {"DELETE", "/smartcostlimit", updateSmartCostLimit(site)},
|
||||
"tariff": {"GET", "/tariff/{tariff:[a-z]+}", tariffHandler(site)},
|
||||
"sessions": {"GET", "/sessions", sessionHandler},
|
||||
"updatesession": {"PUT", "/session/{id:[0-9]+}", updateSessionHandler},
|
||||
|
|
@ -173,7 +176,8 @@ func (s *HTTPd) RegisterSiteHandlers(site site.API, valueChan chan<- util.Param)
|
|||
"remotedemand": {"POST", "/remotedemand/{demand:[a-z]+}/{source:[0-9a-zA-Z_-]+}", remoteDemandHandler(lp)},
|
||||
"enableThreshold": {"POST", "/enable/threshold/{value:-?[0-9.]+}", floatHandler(pass(lp.SetEnableThreshold), lp.GetEnableThreshold)},
|
||||
"disableThreshold": {"POST", "/disable/threshold/{value:-?[0-9.]+}", floatHandler(pass(lp.SetDisableThreshold), lp.GetDisableThreshold)},
|
||||
"smartCostLimit": {"POST", "/smartcostlimit/{value:-?[0-9.]+}", floatHandler(pass(lp.SetSmartCostLimit), lp.GetSmartCostLimit)},
|
||||
"smartCost": {"POST", "/smartcostlimit/{value:-?[0-9.]+}", floatPtrHandler(pass(lp.SetSmartCostLimit), lp.GetSmartCostLimit)},
|
||||
"smartCostDelete": {"DELETE", "/smartcostlimit", floatPtrHandler(pass(lp.SetSmartCostLimit), lp.GetSmartCostLimit)},
|
||||
// "priority": {"POST", "/priority/{value:[0-9.]+}", floatHandler(pass(lp.SetPriority), lp.GetPriority)},
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -131,6 +131,20 @@ func floatHandler(set func(float64) error, get func() float64) http.HandlerFunc
|
|||
return handler(parseFloat, set, get)
|
||||
}
|
||||
|
||||
// floatPtrHandler updates float-pointer api
|
||||
func floatPtrHandler(set func(*float64) error, get func() *float64) http.HandlerFunc {
|
||||
return handler(func(s string) (*float64, error) {
|
||||
var val *float64
|
||||
f, err := parseFloat(s)
|
||||
if err == nil {
|
||||
val = &f
|
||||
} else if s == "" {
|
||||
err = nil
|
||||
}
|
||||
return val, err
|
||||
}, set, get)
|
||||
}
|
||||
|
||||
// intHandler updates int-param api
|
||||
func intHandler(set func(int) error, get func() int) http.HandlerFunc {
|
||||
return handler(strconv.Atoi, set, get)
|
||||
|
|
@ -152,11 +166,16 @@ func boolGetHandler(get func() bool) http.HandlerFunc {
|
|||
func updateSmartCostLimit(site site.API) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
vars := mux.Vars(r)
|
||||
var val *float64
|
||||
|
||||
val, err := parseFloat(vars["value"])
|
||||
if err != nil {
|
||||
jsonError(w, http.StatusBadRequest, err)
|
||||
return
|
||||
if r.Method != http.MethodDelete {
|
||||
f, err := parseFloat(vars["value"])
|
||||
if err != nil {
|
||||
jsonError(w, http.StatusBadRequest, err)
|
||||
return
|
||||
}
|
||||
|
||||
val = &f
|
||||
}
|
||||
|
||||
for _, lp := range site.Loadpoints() {
|
||||
|
|
|
|||
|
|
@ -178,12 +178,12 @@ func (m *MQTT) listenSiteSetters(topic string, site site.API) error {
|
|||
{"/batteryDischargeControl", boolSetter(site.SetBatteryDischargeControl)},
|
||||
{"/prioritySoc", floatSetter(site.SetPrioritySoc)},
|
||||
{"/residualPower", floatSetter(site.SetResidualPower)},
|
||||
{"/smartCostLimit", floatSetter(func(limit float64) error {
|
||||
{"/smartCostLimit", floatPtrSetter(pass(func(limit *float64) {
|
||||
for _, lp := range site.Loadpoints() {
|
||||
lp.SetSmartCostLimit(limit)
|
||||
}
|
||||
return nil
|
||||
})},
|
||||
}))},
|
||||
{"/batteryGridChargeLimit", floatPtrSetter(pass(site.SetBatteryGridChargeLimit))},
|
||||
} {
|
||||
if err := m.Handler.ListenSetter(topic+s.topic, s.fun); err != nil {
|
||||
return err
|
||||
|
|
@ -203,7 +203,7 @@ func (m *MQTT) listenLoadpointSetters(topic string, site site.API, lp loadpoint.
|
|||
{"/limitEnergy", floatSetter(pass(lp.SetLimitEnergy))},
|
||||
{"/enableThreshold", floatSetter(pass(lp.SetEnableThreshold))},
|
||||
{"/disableThreshold", floatSetter(pass(lp.SetDisableThreshold))},
|
||||
{"/smartCostLimit", floatSetter(pass(lp.SetSmartCostLimit))},
|
||||
{"/smartCostLimit", floatPtrSetter(pass(lp.SetSmartCostLimit))},
|
||||
{"/planEnergy", func(payload string) error {
|
||||
var plan struct {
|
||||
Time time.Time `json:"time"`
|
||||
|
|
|
|||
|
|
@ -25,6 +25,18 @@ func floatSetter(set func(float64) error) func(string) error {
|
|||
return setterFunc(parseFloat, set)
|
||||
}
|
||||
|
||||
func floatPtrSetter(set func(*float64) error) func(string) error {
|
||||
return func(s string) error {
|
||||
var val *float64
|
||||
if f, err := parseFloat(s); err == nil {
|
||||
val = &f
|
||||
} else if s != "" {
|
||||
return err
|
||||
}
|
||||
return set(val)
|
||||
}
|
||||
}
|
||||
|
||||
func intSetter(set func(int) error) func(string) error {
|
||||
return setterFunc(strconv.Atoi, set)
|
||||
}
|
||||
|
|
|
|||
71
tests/battery-settings.evcc.yaml
Executable file
71
tests/battery-settings.evcc.yaml
Executable file
|
|
@ -0,0 +1,71 @@
|
|||
interval: 0.1s
|
||||
|
||||
site:
|
||||
title: Battery Settings
|
||||
meters:
|
||||
grid: grid
|
||||
pv: pv
|
||||
battery: battery
|
||||
|
||||
meters:
|
||||
- name: grid
|
||||
type: custom
|
||||
power:
|
||||
source: const
|
||||
value: 1000
|
||||
- name: pv
|
||||
type: custom
|
||||
power:
|
||||
source: const
|
||||
value: 2000
|
||||
- name: battery
|
||||
type: custom
|
||||
power:
|
||||
source: const
|
||||
value: -1000
|
||||
soc:
|
||||
source: const
|
||||
value: 50
|
||||
capacity: 20
|
||||
batterymode:
|
||||
source: js
|
||||
vm: shared
|
||||
script: |
|
||||
1
|
||||
|
||||
loadpoints:
|
||||
- title: Carport
|
||||
charger: charger
|
||||
mode: now
|
||||
|
||||
chargers:
|
||||
- name: charger
|
||||
type: custom
|
||||
enable:
|
||||
source: js
|
||||
script:
|
||||
enabled:
|
||||
source: js
|
||||
script: |
|
||||
true
|
||||
status:
|
||||
source: js
|
||||
script: |
|
||||
"C"
|
||||
maxcurrent:
|
||||
source: js
|
||||
script: |
|
||||
16
|
||||
|
||||
tariffs:
|
||||
currency: EUR
|
||||
grid:
|
||||
type: fixed
|
||||
price: 0.2 # EUR/kWh
|
||||
zones:
|
||||
- hours: 5-7
|
||||
price: 0.6
|
||||
- hours: 11-14
|
||||
price: 0.05
|
||||
- hours: 17-21
|
||||
price: 0.5
|
||||
62
tests/battery-settings.spec.js
Normal file
62
tests/battery-settings.spec.js
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
import { test, expect } from "@playwright/test";
|
||||
import { start, stop, baseUrl } from "./evcc";
|
||||
|
||||
test.use({ baseURL: baseUrl() });
|
||||
|
||||
test.beforeAll(async () => {
|
||||
await start("battery-settings.evcc.yaml", "password.sql");
|
||||
});
|
||||
test.afterAll(async () => {
|
||||
await stop();
|
||||
});
|
||||
|
||||
async function enableExperimental(page) {
|
||||
await page.getByTestId("topnavigation-button").click();
|
||||
await page.getByTestId("topnavigation-settings").click();
|
||||
await page.getByLabel("Experimental 🧪").click();
|
||||
await page.getByRole("button", { name: "Close" }).click();
|
||||
await expect(page.locator(".modal-backdrop")).not.toBeVisible();
|
||||
}
|
||||
|
||||
test.describe("battery settings", async () => {
|
||||
test("open modal", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
await page.getByTestId("topnavigation-button").click();
|
||||
await page.getByTestId("topnavigation-battery").click();
|
||||
|
||||
const modal = page.getByTestId("battery-settings-modal");
|
||||
await expect(modal.getByRole("heading", { name: "Home Battery" })).toBeVisible();
|
||||
await expect(modal.getByRole("link", { name: "Grid charging 🧪" })).not.toBeVisible();
|
||||
await expect(modal).toContainText("Battery level: 50%");
|
||||
await expect(modal).toContainText("10.0 kWh of 20.0 kWh");
|
||||
});
|
||||
|
||||
test("battery usage", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
await page.getByTestId("topnavigation-button").click();
|
||||
await page.getByTestId("topnavigation-battery").click();
|
||||
|
||||
await page.locator("#batterySettingsPriority").selectOption({ label: "50%" });
|
||||
await expect(page.locator("label[for=batterySettingsPriorityMiddle] span")).toHaveText("50%");
|
||||
await expect(page.locator("label[for=batterySettingsPriorityBottom] span")).toHaveText("50%");
|
||||
await page.locator("#batterySettingsBufferTop").selectOption({ label: "80%" });
|
||||
await page.locator("#batterySettingsBufferStart").selectOption({ label: "when above 90%." });
|
||||
await expect(page.locator("label[for=batterySettingsBuffer] span")).toHaveText("80%");
|
||||
});
|
||||
|
||||
test("grid charging", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
await enableExperimental(page);
|
||||
await page.getByTestId("topnavigation-button").click();
|
||||
await page.getByTestId("topnavigation-battery").click();
|
||||
|
||||
await page.getByRole("link", { name: "Grid charging 🧪" }).click();
|
||||
await page.getByLabel("Price limit").selectOption({ label: "≤ 50.0 ct/kWh" });
|
||||
await expect(page.getByTestId("battery-settings-modal")).toContainText("5.0 ct – 50.0 ct");
|
||||
await page.getByRole("button", { name: "Close" }).click();
|
||||
await expect(page.getByTestId("battery-settings-modal")).not.toBeVisible();
|
||||
await page.getByTestId("energyflow").click();
|
||||
await page.getByRole("button", { name: "cheap grid energy (≤ 50.0 ct)" }).click();
|
||||
await expect(page.getByTestId("battery-settings-modal")).toBeVisible();
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue