Add battery grid charging (#14855)

This commit is contained in:
andig 2024-08-03 15:23:50 +02:00 • committed by GitHub
parent bbf989c3c0
commit b0ea0582e2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
37 changed files with 797 additions and 489 deletions

View file

@ -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
}
}

View file

@ -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);

View file

@ -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>

View file

@ -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"

View file

@ -19,7 +19,7 @@ export default {
name: "CustomSelect",
props: {
options: { type: Array },
selected: { type: String },
selected: { type: [String, Number] },
id: { type: String },
},
emits: ["change"],

View file

@ -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() {

View file

@ -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>

View file

@ -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]}`)"

View file

@ -72,7 +72,8 @@ export default {
batteryPower: Number,
batterySoc: Number,
batteryDischargeControl: Boolean,
batterySmartCostLimit: Number,
batteryGridChargeLimit: Number,
batteryGridChargeActive: Boolean,
batteryMode: String,
battery: Array,
gridCurrents: Array,

View file

@ -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);

View file

@ -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>

View file

@ -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>

View file

@ -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

View file

@ -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)
}

View file

@ -39,6 +39,8 @@ const (
// battery settings
BatteryCapacity = "batteryCapacity"
BatteryDischargeControl = "batteryDischargeControl"
BatteryGridChargeLimit = "batteryGridChargeLimit"
BatteryGridChargeActive = "batteryGridChargeActive"
BufferSoc = "bufferSoc"
BufferStartSoc = "bufferStartSoc"
MaxGridSupplyWhileBatteryCharging = "maxGridSupplyWhileBatteryCharging"

View file

@ -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

View file

@ -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

View file

@ -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)
}

View file

@ -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)
}
}
}

View 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{}
}

View file

@ -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()

View file

@ -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

View file

@ -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
}
}

View file

@ -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
}

View file

@ -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)
}

View file

@ -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
//

View file

@ -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)
}
}
}

View file

@ -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
}

View file

@ -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())
}

View file

@ -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"

View file

@ -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"

View file

@ -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)},
}

View file

@ -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() {

View file

@ -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"`

View file

@ -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)
}

View 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

View 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();
});
});