Add battery settings UI (#7802)

This commit is contained in:
Michael Geers 2023-05-08 18:11:31 +02:00 • committed by GitHub
parent 1e6da3670a
commit 9b282e6e4c
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
16 changed files with 725 additions and 67 deletions

View file

@ -121,6 +121,18 @@ h5 {
background-color: var(--evcc-dark-green) !important;
}
.bg-dark-green {
background-color: var(--evcc-dark-green) !important;
}
.bg-darker-green {
background-color: var(--evcc-darker-green) !important;
}
.bg-darkest-green {
background-color: var(--evcc-darkest-green) !important;
}
.text-primary {
color: var(--bs-primary) !important;
}
@ -237,6 +249,9 @@ a:hover {
.text-dark {
color: var(--bs-gray-dark);
}
.text-light {
color: var(--bs-gray-bright);
}
.text-gray {
color: var(--bs-gray-medium);
}

View file

@ -0,0 +1,522 @@
<template>
<Teleport to="body">
<div
id="batterySettingsModal"
ref="modal"
class="modal fade text-dark"
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>
</div>
<div
class="modal-body d-flex justify-content-between align-items-center align-items-sm-start pb-2 flex-column flex-sm-row-reverse"
>
<div class="battery mb-5 mb-sm-3 me-5 me-sm-0">
<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="me-sm-4">
<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"
@change="changeBufferStart"
>
<option
v-for="option in [
'never',
'full',
'medium',
'low',
]"
:key="option"
:value="option"
>
{{
$t(`batterySettings.bufferStart.${option}`)
}}
</option>
</select>
<span class="text-decoration-underline">
{{
$t(
`batterySettings.bufferStart.${bufferStartOption}`
)
}}
</span>
</label>
</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") }}
<small class="d-block">
{{ $t("batterySettings.legendMiddleSubline") }}
</small>
</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>
</span>
</p>
<p>
<small>
{{ $t("batterySettings.note") }}
</small>
</p>
</div>
</div>
</div>
</div>
</div>
</Teleport>
</template>
<script>
import "@h2d2/shopicons/es/regular/lightning";
import "@h2d2/shopicons/es/regular/car3";
import "@h2d2/shopicons/es/regular/home";
import formatter from "../mixins/formatter";
import api from "../api";
export default {
name: "BatterySettingsModal",
mixins: [formatter],
props: {
bufferSoc: Number,
prioritySoc: Number,
batterySoc: Number,
bufferStartSoc: Number,
battery: { type: Array },
},
data: function () {
return {
isModalVisible: false,
selectedBufferSoc: 100,
selectedPrioritySoc: 0,
selectedBufferStartSoc: 0,
};
},
computed: {
priorityOptions() {
const options = [];
for (let i = 100; i >= 0; i -= 5) {
// avoid intersection with buffer soc; allow everything if they touch
const disabled =
i > this.selectedBufferSoc &&
!(this.selectedBufferSoc == this.selectedPrioritySoc);
options.push({ value: i, name: this.fmtSoc(i), disabled });
}
return options;
},
bufferOptions() {
const options = [];
for (let i = 100; i >= 5; i -= 5) {
options.push({
value: i,
name: this.fmtSoc(i),
disabled: i < this.selectedPrioritySoc,
});
}
return options;
},
bufferStartTop() {
if (!this.selectedBufferStartSoc) return 0;
return 100 - this.selectedBufferStartSoc;
},
topHeight() {
return 100 - (this.bufferSoc || 100);
},
middleHeight() {
return 100 - this.topHeight - this.bottomHeight;
},
bottomHeight() {
return this.prioritySoc;
},
bufferStartOption() {
if (!this.selectedBufferStartSoc) return "never";
if (this.selectedBufferStartSoc >= this.bufferStartOptionToSoc("full")) return "full";
if (this.selectedBufferStartSoc >= this.bufferStartOptionToSoc("medium"))
return "medium";
return "low";
},
batteryDetails() {
if (!Array.isArray(this.battery)) {
return;
}
return this.battery.map(({ soc, capacity }) => {
const multipleBatteries = this.battery.length > 1;
const energy = this.fmtKWh(
(capacity / 100) * soc * 1e3,
true,
!multipleBatteries,
1
);
const total = this.fmtKWh(capacity * 1e3, true, true, 1);
const name = multipleBatteries ? "↳ " : "";
const formattedSoc = multipleBatteries ? ` (${this.fmtSoc(soc)})` : "";
const formattedEnergy = this.$t("batterySettings.capacity", {
energy,
total,
});
return `${name}${formattedEnergy}${formattedSoc}`;
});
},
},
watch: {
prioritySoc(soc) {
this.selectedPrioritySoc = soc;
},
bufferSoc(soc) {
this.selectedBufferSoc = soc || 100;
},
bufferStartSoc(soc) {
this.selectedBufferStartSoc = soc;
},
},
mounted() {
this.selectedBufferSoc = this.bufferSoc || 100;
this.selectedPrioritySoc = this.prioritySoc;
this.selectedBufferSoc = this.bufferStart;
this.$refs.modal.addEventListener("show.bs.modal", this.modalVisible);
this.$refs.modal.addEventListener("hidden.bs.modal", this.modalInvisible);
},
unmounted() {
this.$refs.modal?.removeEventListener("show.bs.modal", this.modalVisible);
this.$refs.modal?.removeEventListener("hidden.bs.modal", this.modalInvisible);
},
methods: {
modalVisible: function () {
this.isModalVisible = true;
},
modalInvisible: function () {
this.isModalVisible = false;
},
changeBufferStart($event) {
this.setBufferStart($event.target.value);
},
changePrioritySoc($event) {
const value = parseInt($event.target.value, 10);
if (value > this.bufferSoc) {
this.saveBufferSoc(value);
} else {
this.savePrioritySoc(value);
}
},
bufferStartOptionToSoc(option) {
const bufferSoc = this.selectedBufferSoc;
const bufferHeight = 100 - bufferSoc;
console.log("bufferStartOptionToSoc", { bufferSoc, bufferHeight, option });
switch (option) {
case "low":
return bufferSoc + bufferHeight * 0.2;
case "medium":
return bufferSoc + bufferHeight * 0.5;
case "full":
return bufferSoc + bufferHeight * 0.8;
case "never":
return 0;
}
},
toggleBufferStart() {
const next = {
never: "low",
low: "medium",
medium: "full",
full: "never",
};
const nextOption = next[this.bufferStartOption];
if (nextOption) {
this.setBufferStart(nextOption);
}
},
setBufferStart(option) {
this.selectedBufferStartSoc = this.bufferStartOptionToSoc(option);
this.saveBufferStartSoc(this.selectedBufferStartSoc);
},
changeBufferSoc($event) {
const startOption = this.bufferStartOption;
this.saveBufferSoc(parseInt($event.target.value, 10));
this.setBufferStart(startOption);
},
async savePrioritySoc(soc) {
this.selectedPrioritySoc = soc;
try {
await api.post(`prioritysoc/${encodeURIComponent(soc)}`);
} catch (err) {
console.error(err);
}
},
async saveBufferSoc(soc) {
this.selectedBufferSoc = soc;
try {
await api.post(`buffersoc/${encodeURIComponent(soc)}`);
} catch (err) {
console.error(err);
}
},
async saveBufferStartSoc(soc) {
try {
await api.post(`bufferstartsoc/${encodeURIComponent(soc)}`);
} catch (err) {
console.error(err);
}
},
iconStyle(height) {
let scale = 1;
if (height <= 10) scale = 0.75;
if (height <= 5) scale = 0;
return { transform: `scale(${scale})` };
},
fmtSoc(soc) {
return `${Math.round(soc)}%`;
},
},
};
</script>
<style scoped>
.battery {
height: 300px;
display: flex;
}
.batteryLimits {
width: 50px;
position: relative;
}
.bufferStart,
.bufferSoc,
.prioritySoc {
position: absolute;
transform: translateY(-50%);
transition-property: top, opacity;
transition-timing-function: linear;
transition-duration: var(--evcc-transition-fast);
opacity: 1;
}
.bufferStart--hidden,
.bufferSoc--hidden {
opacity: 0;
pointer-events: none;
}
.batterySoc,
.bufferStartIndicator {
position: absolute;
transition-property: top, opacity;
transition-timing-function: linear;
transition-duration: var(--evcc-transition-fast);
transform: translateY(-50%);
}
.batterySoc {
border-radius: 0.5rem;
left: 0.5rem;
right: 0.5rem;
height: 0.5rem;
opacity: 0.5;
}
.bufferStartIndicator {
display: flex;
justify-content: space-between;
left: 0;
right: 0;
}
.bufferStartIndicator--hidden {
opacity: 0;
transform: translateY(-50%);
}
.bufferStartIndicator__left,
.bufferStartIndicator__right {
height: 0.5rem;
width: 0.5rem;
background-color: var(--evcc-box);
}
.bufferStartIndicator__left {
border-radius: 0 0.5rem 0.5rem 0;
}
.bufferStartIndicator__right {
border-radius: 0.5rem 0 0 0.5rem;
}
.progress {
height: 100%;
width: 100px;
flex-direction: column;
position: relative;
border-radius: 10px;
background-color: var(--evcc-box) !important;
}
.progress-bar {
transition: height var(--evcc-transition-fast) linear;
}
.icon {
transition: transform var(--evcc-transition-fast) linear;
z-index: 1;
border-radius: 0.5rem;
}
.custom-select {
left: 0;
top: 0;
bottom: 0;
right: 0;
position: absolute;
opacity: 0;
-webkit-appearance: menulist-button;
}
</style>

View file

@ -68,7 +68,8 @@
:soc="batterySoc"
:details="batterySoc"
:detailsFmt="batteryFmt"
:detailsTooltip="batteryTooltip"
detailsClickable
@details-clicked="openBatterySettingsModal"
/>
<EnergyflowEntry
:name="$t('main.energyflow.gridImport')"
@ -133,7 +134,8 @@
:soc="batterySoc"
:details="batterySoc"
:detailsFmt="batteryFmt"
:detailsTooltip="batteryTooltip"
detailsClickable
@details-clicked="openBatterySettingsModal"
/>
<EnergyflowEntry
:name="$t('main.energyflow.pvExport')"
@ -149,6 +151,7 @@
</div>
</div>
<GridSettingsModal v-bind="gridSettings" />
<BatterySettingsModal v-bind="batterySettings" />
</div>
</template>
@ -163,10 +166,17 @@ import AnimatedNumber from "../AnimatedNumber.vue";
import settings from "../../settings";
import { CO2_UNIT } from "../../units";
import collector from "../../mixins/collector";
import BatterySettingsModal from "../BatterySettingsModal.vue";
export default {
name: "Energyflow",
components: { Visualization, EnergyflowEntry, AnimatedNumber, GridSettingsModal },
components: {
Visualization,
EnergyflowEntry,
AnimatedNumber,
GridSettingsModal,
BatterySettingsModal,
},
mixins: [formatter, collector],
props: {
gridConfigured: Boolean,
@ -191,6 +201,9 @@ export default {
smartCostLimit: { type: Number },
smartCostUnit: { type: String },
currency: { type: String },
prioritySoc: { type: Number },
bufferSoc: { type: Number },
bufferStartSoc: { type: Number },
},
data: () => {
return { detailsOpen: false, detailsCompleteHeight: null, gridSettingsModal: null };
@ -234,27 +247,15 @@ export default {
}
return this.pv.map(({ power }) => this.fmtKw(power, this.powerInKw));
},
batteryTooltip() {
if (!Array.isArray(this.battery)) {
return;
}
return this.battery.map(({ soc, capacity }) => {
const energy = this.fmtKWh((capacity / 100) * soc * 1e3, true, false, 1);
const total = this.fmtKWh(capacity * 1e3, true, true, 1);
const formattedSoc = this.batteryFmt(soc);
return this.$t("main.energyflow.batteryTooltip", {
energy,
total,
soc: formattedSoc,
});
});
},
batteryFmt() {
return (soc) => `${Math.round(soc)}%`;
},
gridSettings() {
return this.collectProps(GridSettingsModal);
},
batterySettings() {
return this.collectProps(BatterySettingsModal);
},
co2Available() {
return this.smartCostUnit === CO2_UNIT;
},
@ -263,6 +264,9 @@ export default {
this.gridSettingsModal = Modal.getOrCreateInstance(
document.querySelector("#gridSettingsModal")
);
this.batterySettingsModal = Modal.getOrCreateInstance(
document.querySelector("#batterySettingsModal")
);
window.addEventListener("resize", this.updateHeight);
// height must be calculated in case of initially open details
if (settings.energyflowDetails) {
@ -309,6 +313,9 @@ export default {
openGridSettingsModal() {
this.gridSettingsModal.show();
},
openBatterySettingsModal() {
this.batterySettingsModal.show();
},
},
};
</script>

View file

@ -70,6 +70,8 @@ export default {
battery: Array,
gridCurrents: Array,
prioritySoc: Number,
bufferSoc: Number,
bufferStartSoc: Number,
siteTitle: String,
vehicles: Array,

View file

@ -1070,7 +1070,7 @@ func (lp *Loadpoint) publishTimer(name string, delay time.Duration, action strin
}
// pvMaxCurrent calculates the maximum target current for PV mode
func (lp *Loadpoint) pvMaxCurrent(mode api.ChargeMode, sitePower float64, batteryBuffered bool) float64 {
func (lp *Loadpoint) pvMaxCurrent(mode api.ChargeMode, sitePower float64, batteryBuffered, batteryStart bool) float64 {
// read only once to simplify testing
minCurrent := lp.GetMinCurrent()
maxCurrent := lp.GetMaxCurrent()
@ -1427,7 +1427,7 @@ func (lp *Loadpoint) guardGracePeriodElapsed() bool {
}
// Update is the main control function. It reevaluates meters and charger state
func (lp *Loadpoint) Update(sitePower float64, autoCharge, batteryBuffered bool, greenShare float64, effPrice, effCo2 *float64) {
func (lp *Loadpoint) Update(sitePower float64, autoCharge, batteryBuffered, batteryStart bool, greenShare float64, effPrice, effCo2 *float64) {
lp.processTasks()
mode := lp.GetMode()
@ -1526,7 +1526,7 @@ func (lp *Loadpoint) Update(sitePower float64, autoCharge, batteryBuffered bool,
break
}
targetCurrent := lp.pvMaxCurrent(mode, sitePower, batteryBuffered)
targetCurrent := lp.pvMaxCurrent(mode, sitePower, batteryBuffered, batteryStart)
var required bool // false
if targetCurrent == 0 && lp.vehicleClimateActive() {

View file

@ -182,7 +182,7 @@ func TestUpdatePowerZero(t *testing.T) {
}
lp.Mode = tc.mode
lp.Update(0, false, false, 0, nil, nil) // sitePower false,0
lp.Update(0, false, false, false, 0, nil, nil) // false,sitePower false,0
ctrl.Finish()
}
@ -338,7 +338,7 @@ func TestPVHysteresis(t *testing.T) {
// charger.EXPECT().Enabled().Return(tc.enabled, nil)
lp.enabled = tc.enabled
current := lp.pvMaxCurrent(api.ModePV, se.site, false)
current := lp.pvMaxCurrent(api.ModePV, se.site, false, false)
if current != se.current {
t.Errorf("step %d: wanted %.1f, got %.1f", step, se.current, current)
@ -371,7 +371,7 @@ func TestPVHysteresisForStatusOtherThanC(t *testing.T) {
// maxCurrent will read enabled state in PV mode
sitePower := -float64(phases)*minA*Voltage + 1 // 1W below min power
current := lp.pvMaxCurrent(api.ModePV, sitePower, false)
current := lp.pvMaxCurrent(api.ModePV, sitePower, false, false)
if current != 0 {
t.Errorf("PV mode could not disable charger as expected. Expected 0, got %.f", current)
@ -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, false, 0, nil, nil)
lp.Update(500, false, false, false, 0, nil, nil)
t.Log("charging above target - soc deactivates charger")
clock.Add(5 * time.Minute)
@ -436,20 +436,20 @@ 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, false, 0, nil, nil)
lp.Update(500, false, false, false, 0, nil, nil)
t.Log("deactivated charger changes status to B")
clock.Add(5 * time.Minute)
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, false, 0, nil, nil)
lp.Update(-5000, false, false, false, 0, nil, nil)
t.Log("soc has fallen 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, false, 0, nil, nil)
lp.Update(-5000, false, false, false, 0, nil, nil)
t.Log("soc has fallen below target - soc update timer expired")
clock.Add(pollInterval)
@ -457,7 +457,7 @@ func TestDisableAndEnableAtTargetSoc(t *testing.T) {
charger.EXPECT().Status().Return(api.StatusB, nil)
charger.EXPECT().Enabled().Return(lp.enabled, nil)
charger.EXPECT().Enable(true).Return(nil)
lp.Update(-5000, false, false, 0, nil, nil)
lp.Update(-5000, false, false, false, 0, nil, nil)
ctrl.Finish()
}
@ -498,14 +498,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, false, 0, nil, nil)
lp.Update(500, false, 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, false, 0, nil, nil)
lp.Update(-3000, false, false, false, 0, nil, nil)
if lp.Mode != api.ModeOff {
t.Error("unexpected mode", lp.Mode)
@ -568,14 +568,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, false, 0, nil, nil)
lp.Update(-1, false, 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, false, 0, nil, nil)
lp.Update(-1, false, false, false, 0, nil, nil)
expectCache("chargedEnergy", 5000.0)
t.Log("at 1:00h stop charging at 5 kWh")
@ -583,7 +583,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, false, 0, nil, nil)
lp.Update(-1, false, false, false, 0, nil, nil)
expectCache("chargedEnergy", 5000.0)
t.Log("at 1:00h restart charging at 5 kWh")
@ -591,7 +591,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, false, 0, nil, nil)
lp.Update(-1, false, false, false, 0, nil, nil)
expectCache("chargedEnergy", 5000.0)
t.Log("at 1:30h continue charging at 7.5 kWh")
@ -599,7 +599,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, false, 0, nil, nil)
lp.Update(-1, false, false, false, 0, nil, nil)
expectCache("chargedEnergy", 7500.0)
t.Log("at 2:00h stop charging at 10 kWh")
@ -607,7 +607,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, false, 0, nil, nil)
lp.Update(-1, false, false, false, 0, nil, nil)
expectCache("chargedEnergy", 10000.0)
ctrl.Finish()

View file

@ -361,7 +361,7 @@ func TestReconnectVehicle(t *testing.T) {
// vehicle not updated yet
vehicle.MockChargeState.EXPECT().Status().Return(api.StatusA, nil)
lp.Update(0, false, false, 0, nil, nil)
lp.Update(0, false, false, false, 0, nil, nil)
ctrl.Finish()
// detection started
@ -375,7 +375,7 @@ func TestReconnectVehicle(t *testing.T) {
// vehicle not updated yet
vehicle.MockChargeState.EXPECT().Status().Return(api.StatusB, nil)
lp.Update(0, false, false, 0, nil, nil)
lp.Update(0, false, false, false, 0, nil, nil)
ctrl.Finish()
// vehicle detected

View file

@ -17,6 +17,7 @@ import (
"github.com/evcc-io/evcc/core/prioritizer"
"github.com/evcc-io/evcc/push"
serverdb "github.com/evcc-io/evcc/server/db"
"github.com/evcc-io/evcc/server/db/settings"
"github.com/evcc-io/evcc/tariff"
"github.com/evcc-io/evcc/util"
"github.com/evcc-io/evcc/util/telemetry"
@ -27,7 +28,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, autoCharge, batteryBuffered bool, greenShare float64, effectivePrice *float64, effectiveCo2 *float64)
Update(availablePower float64, autoCharge, batteryBuffered, batteryStart bool, greenShare float64, effectivePrice, effectiveCo2 *float64)
}
// meterMeasurement is used as slice element for publishing structured data
@ -58,7 +59,8 @@ type Site struct {
ResidualPower float64 `mapstructure:"residualPower"` // PV meter only: household usage. Grid meter: household safety margin
Meters MetersConfig // Meter references
PrioritySoc float64 `mapstructure:"prioritySoc"` // prefer battery up to this Soc
BufferSoc float64 `mapstructure:"bufferSoc"` // ignore battery above this Soc
BufferSoc float64 `mapstructure:"bufferSoc"` // continue charging on battery above this Soc
BufferStartSoc float64 `mapstructure:"bufferStartSoc"` // start charging on battery above this Soc
MaxGridSupplyWhileBatteryCharging float64 `mapstructure:"maxGridSupplyWhileBatteryCharging"` // ignore battery charging if AC consumption is above this value
SmartCostLimit float64 `mapstructure:"smartCostLimit"` // always charge if cost is below this value
@ -114,6 +116,8 @@ func NewSiteFromConfig(
site.prioritizer = prioritizer.New()
site.savings = NewSavings(tariffs)
site.restoreSettings()
// upload telemetry on shutdown
if telemetry.Enabled() {
shutdown.Register(func() {
@ -179,6 +183,14 @@ func NewSiteFromConfig(
return nil, errors.New("missing either grid or pv meter")
}
if site.BufferStartSoc <= site.BufferSoc {
site.log.WARN.Println("bufferStartSoc must be larger than bufferSoc")
}
if site.PrioritySoc > site.BufferSoc {
site.log.WARN.Println("bufferSoc must be larger than prioritySoc")
}
return site, nil
}
@ -202,6 +214,21 @@ func (site *Site) Loadpoints() []loadpoint.API {
return res
}
func (site *Site) restoreSettings() {
if v, err := settings.Float("site.bufferSoc"); err == nil {
site.BufferSoc = v
}
if v, err := settings.Float("site.bufferStartSoc"); err == nil {
site.BufferStartSoc = v
}
if v, err := settings.Float("site.prioritySoc"); err == nil {
site.PrioritySoc = v
}
if v, err := settings.Float("site.smartCostLimit"); err == nil {
site.SmartCostLimit = v
}
}
func meterCapabilities(name string, meter interface{}) string {
_, power := meter.(api.Meter)
_, energy := meter.(api.MeterEnergy)
@ -491,9 +518,9 @@ func (site *Site) updateMeters() error {
// - the net power exported by the site minus a residual margin
// (negative values mean grid: export, battery: charging
// - if battery buffer can be used for charging
func (site *Site) sitePower(totalChargePower, flexiblePower float64) (float64, bool, error) {
func (site *Site) sitePower(totalChargePower, flexiblePower float64) (float64, bool, bool, error) {
if err := site.updateMeters(); err != nil {
return 0, false, err
return 0, false, false, err
}
// allow using PV as estimate for grid power
@ -515,7 +542,7 @@ func (site *Site) sitePower(totalChargePower, flexiblePower float64) (float64, b
batteryPower := site.batteryPower
// handed to loadpoint
var batteryBuffered bool
var batteryBuffered, batteryStart bool
if len(site.batteryMeters) > 0 {
site.Lock()
@ -528,6 +555,7 @@ func (site *Site) sitePower(totalChargePower, flexiblePower float64) (float64, b
} else {
// if battery is above bufferSoc allow using it for charging
batteryBuffered = site.BufferSoc > 0 && site.batterySoc > site.BufferSoc
batteryStart = site.BufferStartSoc > 0 && site.batterySoc > site.BufferStartSoc
}
}
@ -564,7 +592,7 @@ func (site *Site) sitePower(totalChargePower, flexiblePower float64) (float64, b
site.log.DEBUG.Printf("site power: %.0fW", sitePower)
return sitePower, batteryBuffered, nil
return sitePower, batteryBuffered, batteryStart, nil
}
func (site *Site) greenShare() float64 {
@ -663,9 +691,9 @@ func (site *Site) update(lp Updater) {
}
}
if sitePower, batteryBuffered, err := site.sitePower(totalChargePower, flexiblePower); err == nil {
if sitePower, batteryBuffered, batteryStart, err := site.sitePower(totalChargePower, flexiblePower); err == nil {
greenShare := site.greenShare()
lp.Update(sitePower, autoCharge, batteryBuffered, greenShare, site.effectivePrice(greenShare), site.effectiveCo2(greenShare))
lp.Update(sitePower, autoCharge, batteryBuffered, batteryStart, greenShare, site.effectivePrice(greenShare), site.effectiveCo2(greenShare))
// ignore negative pvPower values as that means it is not an energy source but consumption
homePower := site.gridPower + math.Max(0, site.pvPower) + site.batteryPower - totalChargePower
@ -693,12 +721,16 @@ func (site *Site) prepare() {
site.publish("pvConfigured", len(site.pvMeters) > 0)
site.publish("batteryConfigured", len(site.batteryMeters) > 0)
site.publish("bufferSoc", site.BufferSoc)
site.publish("bufferStartSoc", site.BufferStartSoc)
site.publish("prioritySoc", site.PrioritySoc)
site.publish("residualPower", site.ResidualPower)
site.publish("smartCostLimit", site.SmartCostLimit)
if tariff := site.GetTariff(PlannerTariff); tariff != nil {
site.publish("smartCostUnit", tariff.Unit())
site.publish("smartCostAvailable", tariff.IsDynamic())
} else {
site.publish("smartCostUnit", nil)
site.publish("smartCostAvailable", nil)
}
site.publish("currency", site.tariffs.Currency.String())
site.publish("savingsSince", site.savings.Since())

View file

@ -16,6 +16,8 @@ type API interface {
GetBufferSoc() float64
SetBufferSoc(float64) error
GetBufferStartSoc() float64
SetBufferStartSoc(float64) error
GetPrioritySoc() float64
SetPrioritySoc(float64) error

View file

@ -5,6 +5,7 @@ import (
"github.com/evcc-io/evcc/api"
"github.com/evcc-io/evcc/core/site"
"github.com/evcc-io/evcc/server/db/settings"
)
var _ site.API = (*Site)(nil)
@ -32,6 +33,7 @@ func (site *Site) SetPrioritySoc(soc float64) error {
}
site.PrioritySoc = soc
settings.SetFloat("site.prioritySoc", site.PrioritySoc)
site.publish("prioritySoc", site.PrioritySoc)
return nil
@ -54,11 +56,35 @@ func (site *Site) SetBufferSoc(soc float64) error {
}
site.BufferSoc = soc
settings.SetFloat("site.bufferSoc", site.BufferSoc)
site.publish("bufferSoc", site.BufferSoc)
return nil
}
// GetBufferStartSoc returns the BufferStartSoc
func (site *Site) GetBufferStartSoc() float64 {
site.Lock()
defer site.Unlock()
return site.BufferStartSoc
}
// SetBufferStartSoc sets the BufferStartSoc
func (site *Site) SetBufferStartSoc(soc float64) error {
site.Lock()
defer site.Unlock()
if len(site.batteryMeters) == 0 {
return errors.New("battery not configured")
}
site.BufferStartSoc = soc
settings.SetFloat("site.bufferStartSoc", site.BufferStartSoc)
site.publish("bufferStartSoc", site.BufferStartSoc)
return nil
}
// GetResidualPower returns the ResidualPower
func (site *Site) GetResidualPower() float64 {
site.Lock()
@ -90,6 +116,7 @@ func (site *Site) SetSmartCostLimit(val float64) error {
defer site.Unlock()
site.SmartCostLimit = val
settings.SetFloat("site.smartCostLimit", site.SmartCostLimit)
site.publish("smartCostLimit", site.SmartCostLimit)
return nil

View file

@ -109,11 +109,12 @@ site:
- battery # list of battery meters
aux:
- aux # list of auxiliary meters for adjusting grid operating point
residualPower: # additional household usage margin
prioritySoc: # give home battery priority up to this soc (empty to disable)
bufferSoc: # ignore home battery discharge above soc (empty to disable)
maxGridSupplyWhileBatteryCharging: # ignore battery charging if AC consumption is above this value
smartCostLimit: # set cost limit for automatic charging in PV mode
residualPower: 0 # additional household usage margin
prioritySoc: 0 # give home battery priority up to this soc (empty to disable)
bufferSoc: 0 # continue charging on battery above soc (0 to disable)
bufferStartSoc: 0 # start charging on battery above soc (0 to disable)
maxGridSupplyWhileBatteryCharging: 0 # ignore battery charging if AC consumption is above this value
smartCostLimit: 0 # set cost limit for automatic charging in PV mode
# loadpoint describes the charger, charge meter and connected vehicle
loadpoints:

View file

@ -1,3 +1,23 @@
[batterySettings]
batteryLevel = "Ladestand"
capacity = "{energy} von {total}"
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"
modalTitle = "Batterie Einstellungen"
note = "Hinweis: Nur relevant im PV Modus. Das Ladeverhalten wird entsprechend angepasst."
[batterySettings.bufferStart]
full = "wenn fast voll"
low = "wenn etwas voll"
medium = "wenn halbvoll"
never = "nur mit genug PV-Überschuss"
[footer]
[footer.community]

View file

@ -1,3 +1,23 @@
[batterySettings]
batteryLevel = "Battery level"
capacity = "{energy} of {total}"
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"
modalTitle = "Battery settings"
note = "Note: These settings only affect the solar mode. Charging behaviour is adjusted accordingly."
[batterySettings.bufferStart]
full = "when nearly full"
low = "when somewhat full"
medium = "when half full"
never = "only with enough surplus"
[footer]
[footer.community]

View file

@ -209,6 +209,9 @@
"bufferSoc": {
"type": "number"
},
"bufferStartSoc": {
"type": "number"
},
"maxGridSupplyWhileBatteryCharging": {
"type": "number"
},

View file

@ -95,21 +95,22 @@ func (s *HTTPd) RegisterSiteHandlers(site site.API, cache *util.Cache) {
// site api
routes := map[string]route{
"health": {[]string{"GET"}, "/health", healthHandler(site)},
"state": {[]string{"GET"}, "/state", stateHandler(cache)},
"config": {[]string{"GET"}, "/config/templates/{class:[a-z]+}", templatesHandler},
"products": {[]string{"GET"}, "/config/products/{class:[a-z]+}", productsHandler},
"test": {[]string{"POST"}, "/config/test/{class:[a-z]+}", testHandler},
"buffersoc": {[]string{"POST", "OPTIONS"}, "/buffersoc/{value:[0-9.]+}", floatHandler(site.SetBufferSoc, site.GetBufferSoc)},
"prioritysoc": {[]string{"POST", "OPTIONS"}, "/prioritysoc/{value:[0-9.]+}", floatHandler(site.SetPrioritySoc, site.GetPrioritySoc)},
"residualpower": {[]string{"POST", "OPTIONS"}, "/residualpower/{value:[-0-9.]+}", floatHandler(site.SetResidualPower, site.GetResidualPower)},
"smartcost": {[]string{"POST", "OPTIONS"}, "/smartcostlimit/{value:[-0-9.]+}", floatHandler(site.SetSmartCostLimit, site.GetSmartCostLimit)},
"tariff": {[]string{"GET"}, "/tariff/{tariff:[a-z]+}", tariffHandler(site)},
"sessions": {[]string{"GET"}, "/sessions", sessionHandler},
"session1": {[]string{"PUT"}, "/session/{id:[0-9]+}", updateSessionHandler},
"session2": {[]string{"DELETE"}, "/session/{id:[0-9]+}", deleteSessionHandler},
"telemetry": {[]string{"GET"}, "/settings/telemetry", boolGetHandler(telemetry.Enabled)},
"telemetry2": {[]string{"POST", "OPTIONS"}, "/settings/telemetry/{value:[a-z]+}", boolHandler(telemetry.Enable, telemetry.Enabled)},
"health": {[]string{"GET"}, "/health", healthHandler(site)},
"state": {[]string{"GET"}, "/state", stateHandler(cache)},
"config": {[]string{"GET"}, "/config/templates/{class:[a-z]+}", templatesHandler},
"products": {[]string{"GET"}, "/config/products/{class:[a-z]+}", productsHandler},
"test": {[]string{"POST"}, "/config/test/{class:[a-z]+}", testHandler},
"buffersoc": {[]string{"POST", "OPTIONS"}, "/buffersoc/{value:[0-9.]+}", floatHandler(site.SetBufferSoc, site.GetBufferSoc)},
"bufferstartsoc": {[]string{"POST", "OPTIONS"}, "/bufferstartsoc/{value:[0-9.]+}", floatHandler(site.SetBufferStartSoc, site.GetBufferStartSoc)},
"prioritysoc": {[]string{"POST", "OPTIONS"}, "/prioritysoc/{value:[0-9.]+}", floatHandler(site.SetPrioritySoc, site.GetPrioritySoc)},
"residualpower": {[]string{"POST", "OPTIONS"}, "/residualpower/{value:[-0-9.]+}", floatHandler(site.SetResidualPower, site.GetResidualPower)},
"smartcost": {[]string{"POST", "OPTIONS"}, "/smartcostlimit/{value:[-0-9.]+}", floatHandler(site.SetSmartCostLimit, site.GetSmartCostLimit)},
"tariff": {[]string{"GET"}, "/tariff/{tariff:[a-z]+}", tariffHandler(site)},
"sessions": {[]string{"GET"}, "/sessions", sessionHandler},
"session1": {[]string{"PUT"}, "/session/{id:[0-9]+}", updateSessionHandler},
"session2": {[]string{"DELETE"}, "/session/{id:[0-9]+}", deleteSessionHandler},
"telemetry": {[]string{"GET"}, "/settings/telemetry", boolGetHandler(telemetry.Enabled)},
"telemetry2": {[]string{"POST", "OPTIONS"}, "/settings/telemetry/{value:[a-z]+}", boolHandler(telemetry.Enable, telemetry.Enabled)},
}
for _, r := range routes {

View file

@ -197,6 +197,12 @@ func (m *MQTT) Run(site site.API, in <-chan util.Param) {
}
})
m.Handler.ListenSetter(fmt.Sprintf("%s/site/bufferStartSoc/set", m.root), func(payload string) {
if val, err := strconv.ParseFloat(payload, 64); err == nil {
_ = site.SetBufferStartSoc(val)
}
})
m.Handler.ListenSetter(fmt.Sprintf("%s/site/residualPower/set", m.root), func(payload string) {
if val, err := strconv.ParseFloat(payload, 64); err == nil {
_ = site.SetResidualPower(val)