Add Loadpoint Settings Modal (MinSoC, Phases) (#4121)

This commit is contained in:
Michael Geers 2022-08-17 16:38:55 +02:00 • committed by GitHub
parent 5e0c67c56b
commit 9e3111dedf
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
18 changed files with 655 additions and 149 deletions

View file

@ -265,10 +265,8 @@ small {
opacity: 1;
}
@media (--dark-mode) {
.btn-close {
filter: invert(1);
}
.dark .btn-close {
filter: invert(1);
}
.dropdown-menu {
@ -285,8 +283,11 @@ small {
color: var(--bs-white);
}
@media (--dark-mode) {
.dropdown-menu {
box-shadow: 0 0 8px var(--evcc-background);
}
.dark .dropdown-menu {
box-shadow: 0 0 8px var(--evcc-background);
}
.dark .form-select {
background-color: var(--evcc-box);
color: var(--evcc-default-text);
background-image: url("data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'><path fill='none' stroke='%23ffffff' stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='m2 5 6 6 6-6'/></svg>");
}

View file

@ -1,16 +1,24 @@
<template>
<div class="loadpoint pt-4 pb-2 px-3 px-sm-4 mx-2 mx-sm-0">
<div class="d-block d-sm-flex justify-content-between align-items-center mb-3">
<h3 class="mb-3 me-2 text-truncate">
{{ title || $t("main.loadpoint.fallbackName") }}
</h3>
<div class="d-flex justify-content-between align-items-center mb-3">
<h3 class="me-2 mb-0 text-truncate">
{{ title || $t("main.loadpoint.fallbackName") }}
</h3>
<LoadpointSettingsButton :id="id" class="d-block d-sm-none" />
</div>
<div class="mb-3 d-flex align-items-center">
<Mode class="w-100 w-sm-auto" :mode="mode" @updated="setTargetMode" />
<button v-if="$hiddenFeatures" class="btn btn-link text-gray p-0 flex-shrink-0">
<shopicon-filled-options size="s"></shopicon-filled-options>
</button>
<Mode class="flex-grow-1" :mode="mode" @updated="setTargetMode" />
<LoadpointSettingsButton :id="id" class="d-none d-sm-block ms-2" />
</div>
</div>
<LoadpointSettingsModal
v-bind="settingsModal"
@maxcurrent-updated="setMaxCurrent"
@mincurrent-updated="setMinCurrent"
@phasesconfigured-updated="setPhasesConfigured"
@minsoc-updated="setMinSoC"
/>
<div
v-if="remoteDisabled"
@ -86,7 +94,7 @@
<script>
import "@h2d2/shopicons/es/regular/lightning";
import "@h2d2/shopicons/es/filled/options";
import "@h2d2/shopicons/es/regular/adjust";
import api from "../api";
import Mode from "./Mode.vue";
import Vehicle from "./Vehicle.vue";
@ -94,10 +102,19 @@ import Phases from "./Phases.vue";
import LabelAndValue from "./LabelAndValue.vue";
import formatter from "../mixins/formatter";
import collector from "../mixins/collector";
import LoadpointSettingsButton from "./LoadpointSettingsButton.vue";
import LoadpointSettingsModal from "./LoadpointSettingsModal.vue";
export default {
name: "Loadpoint",
components: { Mode, Vehicle, Phases, LabelAndValue },
components: {
Mode,
Vehicle,
Phases,
LabelAndValue,
LoadpointSettingsButton,
LoadpointSettingsModal,
},
mixins: [formatter, collector],
props: {
id: Number,
@ -138,6 +155,7 @@ export default {
// other information
phases: Number,
phasesConfigured: Number,
minCurrent: Number,
maxCurrent: Number,
phasesActive: Number,
@ -165,6 +183,9 @@ export default {
phasesProps: function () {
return this.collectProps(Phases);
},
settingsModal: function () {
return this.collectProps(LoadpointSettingsModal);
},
vehicle: function () {
return this.collectProps(Vehicle);
},
@ -216,6 +237,18 @@ export default {
setTargetSoC: function (soc) {
api.post(this.apiPath("targetsoc") + "/" + soc);
},
setMaxCurrent: function (maxCurrent) {
api.post(this.apiPath("maxcurrent") + "/" + maxCurrent);
},
setMinCurrent: function (minCurrent) {
api.post(this.apiPath("mincurrent") + "/" + minCurrent);
},
setPhasesConfigured: function (phases) {
api.post(this.apiPath("phases") + "/" + phases);
},
setMinSoC: function (soc) {
api.post(this.apiPath("minsoc") + "/" + soc);
},
setTargetTime: function (date) {
api.post(`${this.apiPath("targetcharge")}/${this.targetSoC}/${date.toISOString()}`);
},

View file

@ -0,0 +1,21 @@
<template>
<button
type="button"
data-bs-toggle="modal"
:data-bs-target="`#loadpointSettingsModal_${id}`"
class="btn btn-sm btn-outline-secondary position-relative border-0 p-2 evcc-gray"
>
<shopicon-regular-adjust size="s"></shopicon-regular-adjust>
</button>
</template>
<script>
import "@h2d2/shopicons/es/regular/adjust";
export default {
name: "LoadpointSettingsButton",
props: {
id: [String, Number],
},
};
</script>

View file

@ -0,0 +1,326 @@
<template>
<Teleport to="body">
<div
:id="`loadpointSettingsModal_${id}`"
class="modal fade text-dark"
data-bs-backdrop="true"
tabindex="-1"
role="dialog"
aria-hidden="true"
>
<div class="modal-dialog modal-dialog-centered modal-dialog-scrollable" role="document">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">
{{ $t("main.loadpointSettings.title", [title]) }}
</h5>
<button
type="button"
class="btn-close"
data-bs-dismiss="modal"
aria-label="Close"
></button>
</div>
<div class="modal-body">
<div class="container">
<h4 class="d-flex align-items-center mb-3 mt-0 text-evcc">
{{ $t("main.loadpointSettings.vehicle") }}
<shopicon-bold-car3 class="ms-2"></shopicon-bold-car3>
</h4>
<div class="mb-3 row">
<label
:for="formId('minsoc')"
class="col-sm-4 col-form-label pt-0 pt-sm-1"
>
{{ $t("main.loadpointSettings.minSoC.label") }}
</label>
<div class="col-sm-8 pe-0">
<select
:id="formId('minsoc')"
v-model.number="selectedMinSoC"
class="form-select form-select-sm mb-2 w-50"
@change="changeMinSoC"
>
<option
v-for="soc in [
0, 5, 10, 15, 20, 25, 30, 35, 40, 45, 50,
]"
:key="soc"
:value="soc"
>
{{ soc ? `${soc}%` : "--" }}
</option>
</select>
<small>
{{
$t("main.loadpointSettings.minSoC.description", [
selectedMinSoC || "x",
])
}}
</small>
</div>
</div>
<h4
v-if="showConfigurablePhases || showCurrentSettings"
class="d-flex align-items-center mb-3 mt-4 text-evcc"
>
{{ $t("main.loadpointSettings.currents") }}
<shopicon-bold-lightning class="ms-1"></shopicon-bold-lightning>
</h4>
<div v-if="showConfigurablePhases" class="mb-3 row">
<label
:for="formId('phases_0')"
class="col-sm-4 col-form-label pt-0"
>
{{ $t("main.loadpointSettings.phasesConfigured.label") }}
</label>
<div class="col-sm-8 pe-0">
<div class="form-check">
<input
:id="formId('phases_0')"
v-model.number="selectedPhases"
class="form-check-input"
type="radio"
:name="formId('phases')"
:value="0"
@change="changePhasesConfigured"
/>
<label class="form-check-label" :for="formId('phases_0')">
{{
$t(
"main.loadpointSettings.phasesConfigured.phases_0"
)
}}
</label>
</div>
<div class="form-check">
<input
:id="formId('phases_1')"
v-model.number="selectedPhases"
class="form-check-input"
type="radio"
:name="formId('phases')"
:value="1"
@change="changePhasesConfigured"
/>
<label class="form-check-label" :for="formId('phases_1')">
{{
$t(
"main.loadpointSettings.phasesConfigured.phases_1"
)
}}
<small>
{{
$t(
"main.loadpointSettings.phasesConfigured.phases_1_hint",
{ min: minPower1p, max: maxPower1p }
)
}}
</small>
</label>
</div>
<div class="form-check">
<input
:id="formId('phases_3')"
v-model.number="selectedPhases"
class="form-check-input"
type="radio"
:name="formId('phases')"
:value="3"
@change="changePhasesConfigured"
/>
<label class="form-check-label" :for="formId('phases_3')">
{{
$t(
"main.loadpointSettings.phasesConfigured.phases_3"
)
}}
<small>
{{
$t(
"main.loadpointSettings.phasesConfigured.phases_3_hint",
{ min: minPower3p, max: maxPower3p }
)
}}
</small>
</label>
</div>
</div>
</div>
<div v-if="$hiddenFeatures" class="mb-3 row">
<label
:for="formId('maxcurrent')"
class="col-sm-4 col-form-label pt-0 pt-sm-2"
>
{{ $t("main.loadpointSettings.maxCurrent.label") }}
</label>
<div class="col-sm-8 pe-0 d-flex align-items-center">
<select
:id="formId('maxcurrent')"
v-model.number="selectedMaxCurrent"
class="form-select form-select-sm w-50"
@change="changeMaxCurrent"
>
<option
v-for="{ value, name } in currentOptions(true, 16)"
:key="value"
:value="value"
>
{{ name }}
</option>
</select>
<small class="ms-3">~ {{ maxPower }}</small>
</div>
</div>
<div v-if="$hiddenFeatures" class="mb-3 row">
<label
:for="formId('mincurrent')"
class="col-sm-4 col-form-label pt-0 pt-sm-2"
>
{{ $t("main.loadpointSettings.minCurrent.label") }}
</label>
<div class="col-sm-8 pe-0 d-flex align-items-center">
<select
:id="formId('mincurrent')"
v-model.number="selectedMinCurrent"
class="form-select form-select-sm w-50"
@change="changeMinCurrent"
>
<option
v-for="{ value, name } in currentOptions(false, 6)"
:key="value"
:value="value"
>
{{ name }}
</option>
</select>
<small class="ms-3">~ {{ minPower }}</small>
</div>
</div>
</div>
<p class="small mt-3 text-muted mb-0">
<strong class="text-evcc">
{{ $t("main.loadpointSettings.disclaimerHint") }}
</strong>
{{ $t("main.loadpointSettings.disclaimerText") }}
</p>
</div>
</div>
</div>
</div>
</Teleport>
</template>
<script>
import "@h2d2/shopicons/es/bold/lightning";
import "@h2d2/shopicons/es/bold/car3";
import formatter from "../mixins/formatter";
const V = 230;
export default {
name: "LoadpointSettingsModal",
mixins: [formatter],
props: {
id: [String, Number],
phasesConfigured: Number,
minSoC: Number,
maxCurrent: Number,
minCurrent: Number,
title: String,
},
emits: [
"phasesconfigured-updated",
"maxcurrent-updated",
"mincurrent-updated",
"minsoc-updated",
],
data: function () {
return {
selectedMaxCurrent: this.maxCurrent,
selectedMinCurrent: this.minCurrent,
selectedPhases: this.phasesConfigured,
selectedMinSoC: this.minSoC,
};
},
computed: {
maxPower1p: function () {
return this.fmtKw(this.maxCurrent * V);
},
minPower1p: function () {
return this.fmtKw(this.minCurrent * V);
},
maxPower3p: function () {
return this.fmtKw(this.maxCurrent * V * 3);
},
minPower3p: function () {
return this.fmtKw(this.minCurrent * V * 3);
},
maxPower: function () {
return this.phasesConfigured === 1 ? this.maxPower1p : this.maxPower3p;
},
minPower: function () {
return this.phasesConfigured === 3 ? this.minPower3p : this.minPower1p;
},
showConfigurablePhases: function () {
return [0, 1, 3].includes(this.phasesConfigured);
},
showCurrentSettings: function () {
return this.$hiddenFeatures;
},
},
watch: {
maxCurrent: function (value) {
this.selectedMaxCurrent = value;
},
minCurrent: function (value) {
this.selectedMinCurrent = value;
},
phasesConfigured: function (value) {
this.selectedPhases = value;
},
minSoC: function (value) {
this.selectedMinSoC = value;
},
},
methods: {
formId: function (name) {
return `loadpoint_${this.id}_${name}`;
},
changeMaxCurrent: function () {
this.$emit("maxcurrent-updated", this.selectedMaxCurrent);
},
changeMinCurrent: function () {
this.$emit("mincurrent-updated", this.selectedMinCurrent);
},
changePhasesConfigured: function () {
this.$emit("phasesconfigured-updated", this.selectedPhases);
},
changeMinSoC: function () {
this.$emit("minsoc-updated", this.selectedMinSoC);
},
currentOptions: function (max, defaultCurrent = 16) {
const result = [];
const toValue = max ? 32 : this.maxCurrent;
const fromValue = max ? this.minCurrent : 6;
for (let value = toValue; value >= fromValue; value--) {
let name = `${value} A`;
if (value === defaultCurrent) {
name += ` (${this.$t("main.loadpointSettings.default")})`;
}
result.push({ value, name });
}
return result;
},
},
};
</script>
<style scoped>
.container {
margin-left: calc(var(--bs-gutter-x) * -0.5);
margin-right: calc(var(--bs-gutter-x) * -0.5);
}
</style>

View file

@ -22,7 +22,7 @@ export default {
emits: ["updated"],
data() {
return {
modes: ["off", "minpv", "pv", "now"],
modes: ["off", "pv", "minpv", "now"],
};
},
methods: {
@ -41,6 +41,7 @@ export default {
border: 2px solid var(--evcc-default-text);
border-radius: 20px;
padding: 4px;
min-width: 255px;
}
.btn {

View file

@ -37,7 +37,7 @@ export default {
props: {
title: String,
icon: String,
value: String,
value: [String, Number],
unit: String,
sub1: String,
sub2: String,

View file

@ -20,7 +20,7 @@
:loadpoints="loadpoints"
:vehicles="vehicles"
/>
<Vehicles v-if="$hiddenFeatures" />
<Vehicles v-if="showParkingLot" />
<Footer v-bind="footer"></Footer>
</div>
</div>
@ -108,6 +108,10 @@ export default {
const vehicleLogins = this.auth ? this.auth.vehicles : {};
return { vehicleLogins };
},
showParkingLot: function () {
// work in progess
return false;
},
footer: function () {
return {
version: {

View file

@ -1,17 +1,17 @@
<template>
<div>
<button
type="button"
<div
role="button"
tabindex="0"
data-bs-toggle="dropdown"
data-bs-target="#navbarNavAltMarkup"
aria-controls="navbarNavAltMarkup"
aria-expanded="false"
aria-label="Toggle navigation"
class="btn btn-sm btn-outline-secondary position-relative border-0 menu-button"
>
<shopicon-filled-options size="s"></shopicon-filled-options>
</button>
<ul class="dropdown-menu dropdown-menu-end">
<slot />
</div>
<ul class="dropdown-menu dropdown-menu-start">
<li>
<h6 class="dropdown-header">{{ $t("main.vehicle.changeVehicle") }}</h6>
</li>

View file

@ -1,16 +1,5 @@
<template>
<div class="d-flex justify-content-between mb-3 align-items-center">
<h4 class="d-flex align-items-center m-0 flex-grow-1 overflow-hidden">
<shopicon-regular-car3
v-if="carIcon"
class="me-2 flex-shrink-0 car-icon"
></shopicon-regular-car3>
<shopicon-regular-cablecharge
v-else
class="me-2 flex-shrink-0 car-icon"
></shopicon-regular-cablecharge>
<span class="flex-grow-1 text-truncate"> {{ name }} </span>
</h4>
<VehicleOptions
v-if="showOptions"
class="options"
@ -18,7 +7,21 @@
:is-unknown="isUnknown"
@change-vehicle="changeVehicle"
@remove-vehicle="removeVehicle"
/>
>
<h4 class="d-flex align-items-center m-0 flex-grow-1 overflow-hidden">
<shopicon-regular-car3
v-if="carIcon"
class="me-2 flex-shrink-0 car-icon"
></shopicon-regular-car3>
<shopicon-regular-cablecharge
v-else
class="me-2 flex-shrink-0 car-icon"
></shopicon-regular-cablecharge>
<span class="flex-grow-1 text-truncate vehicle-name">
{{ name }}
</span>
</h4>
</VehicleOptions>
</div>
</template>
@ -82,4 +85,8 @@ export default {
.options {
margin-right: -0.75rem;
}
.vehicle-name {
text-decoration: underline;
text-decoration-color: var(--evcc-gray);
}
</style>

View file

@ -84,6 +84,34 @@ export default {
duration: "Dauer",
remaining: "Restzeit",
},
loadpointSettings: {
title: 'Einstellungen "{0}"',
vehicle: "Fahrzeug",
currents: "Ladestrom",
minSoC: {
label: "Mindestladestand",
description:
'Reichweite für Notfälle. Fahrzeug wird im PV-Modus "schnell" auf {0}% geladen. Danach weiter mit PV-Überschuss.',
},
phasesConfigured: {
label: "Phasen",
phases_0: "automatischer Wechsel",
phases_1: "1-phasig",
phases_1_hint: "({min} bis {max})",
phases_3: "3-phasig",
phases_3_hint: "({min} bis {max})",
},
maxCurrent: {
label: "Max. Ladestrom",
},
minCurrent: {
label: "Min. Ladestrom",
},
default: "default",
disclaimerHint: "Hinweis:",
disclaimerText:
"Änderungen sind aktuell noch nicht persistent und werden nach einem Serverneustart wieder zurückgesetzt.",
},
vehicles: "Parkplatz",
vehicle: {
fallbackName: "Fahrzeug",

View file

@ -84,6 +84,33 @@ export default {
duration: "Duration",
remaining: "Remaining",
},
loadpointSettings: {
title: 'Settings "{0}"',
vehicle: "Vehicle",
currents: "Charging",
minSoC: {
label: "Minimal SoC",
description:
'Range for emergencies. Vehicle gets "fast" charged to {0}% in PV mode. Then continues with PV surplus only.',
},
phasesConfigured: {
label: "Phases",
phases_0: "automatic switching",
phases_1: "1 phase",
phases_1_hint: "({min} to {max})",
phases_3: "3 phases",
phases_3_hint: "({min} to {max})",
},
maxCurrent: {
label: "Max. Current",
},
minCurrent: {
label: "Min. Current",
},
default: "default",
disclaimerHint: "Note:",
disclaimerText: "Changes are not persistent yet. They will be reset after server restart.",
},
vehicles: "Parking",
vehicle: {
fallbackName: "Vehicle",

View file

@ -20,15 +20,14 @@ func init() {
registry.Add(api.Custom, NewConfigurableFromConfig)
}
//go:generate go run ../cmd/tools/decorate.go -f decorateCustom -b *Charger -r api.Charger -t "api.Identifier,Identify,func() (string, error)"
// go:generate go run ../cmd/tools/decorate.go -f decorateCustom -b *Charger -r api.Charger -t "api.Identifier,Identify,func() (string, error)" -t "api.PhaseSwitcher,Phases1p3p,func(int) (error)"
// NewConfigurableFromConfig creates a new configurable charger
func NewConfigurableFromConfig(other map[string]interface{}) (api.Charger, error) {
var cc struct {
Status, Enable, Enabled, MaxCurrent provider.Config
Identify *provider.Config
Identify, Phases1p3p *provider.Config
}
if err := util.DecodeOther(other, &cc); err != nil {
return nil, err
}
@ -55,13 +54,24 @@ func NewConfigurableFromConfig(other map[string]interface{}) (api.Charger, error
c, err := NewConfigurable(status, enabled, enable, maxcurrent)
// decorate identifier
if err == nil && cc.Identify != nil {
identify, err := provider.NewStringGetterFromConfig(*cc.Identify)
return decorateCustom(c, identify), err
// decorator phases
var phases1p3p func(int) error
if err == nil && cc.Phases1p3p != nil {
var phases1p3pi64 func(int64) error
phases1p3pi64, err = provider.NewIntSetterFromConfig("phases", *cc.Phases1p3p)
phases1p3p = func(phases int) error {
return phases1p3pi64(int64(phases))
}
}
return c, err
// decorator identifier
var identify func() (string, error)
if err == nil && cc.Identify != nil {
identify, err = provider.NewStringGetterFromConfig(*cc.Identify)
}
return decorateCustom(c, identify, phases1p3p), err
}
// NewConfigurable creates a new charger

View file

@ -6,12 +6,12 @@ import (
"github.com/evcc-io/evcc/api"
)
func decorateCustom(base *Charger, identifier func() (string, error)) api.Charger {
func decorateCustom(base *Charger, identifier func() (string, error), phaseSwitcher func(int) error) api.Charger {
switch {
case identifier == nil:
case identifier == nil && phaseSwitcher == nil:
return base
case identifier != nil:
case identifier != nil && phaseSwitcher == nil:
return &struct {
*Charger
api.Identifier
@ -21,6 +21,32 @@ func decorateCustom(base *Charger, identifier func() (string, error)) api.Charge
identifier: identifier,
},
}
case identifier == nil && phaseSwitcher != nil:
return &struct {
*Charger
api.PhaseSwitcher
}{
Charger: base,
PhaseSwitcher: &decorateCustomPhaseSwitcherImpl{
phaseSwitcher: phaseSwitcher,
},
}
case identifier != nil && phaseSwitcher != nil:
return &struct {
*Charger
api.Identifier
api.PhaseSwitcher
}{
Charger: base,
Identifier: &decorateCustomIdentifierImpl{
identifier: identifier,
},
PhaseSwitcher: &decorateCustomPhaseSwitcherImpl{
phaseSwitcher: phaseSwitcher,
},
}
}
return nil
@ -33,3 +59,11 @@ type decorateCustomIdentifierImpl struct {
func (impl *decorateCustomIdentifierImpl) Identify() (string, error) {
return impl.identifier()
}
type decorateCustomPhaseSwitcherImpl struct {
phaseSwitcher func(int) error
}
func (impl *decorateCustomPhaseSwitcherImpl) Phases1p3p(phases int) error {
return impl.phaseSwitcher(phases)
}

View file

@ -104,6 +104,13 @@ chargers:
var lp = state.loadpoints[0];
lp.maxcurrent = val;
if (lp.enabled) lp.chargepower = lp.maxcurrent * 230 * lp.phases; else lp.chargepower = 0;
phases1p3p:
source: js
vm: shared
script: |
set();
if (val === 1) lp.phases = 1; else lp.phases = 3;
lp.phases;
- name: charger_2
type: custom
@ -133,6 +140,13 @@ chargers:
var lp = state.loadpoints[1];
lp.maxcurrent = val;
if (lp.enabled) lp.chargepower = lp.maxcurrent * 230 * lp.phases; else lp.chargepower = 0;
phases1p3p:
source: js
vm: shared
script: |
set();
if (val === 1) lp.phases = 1; else lp.phases = 3;
lp.phases;
vehicles:
- name: vehicle_1

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

97
dist/assets/index.f014e26a.js vendored Normal file

File diff suppressed because one or more lines are too long

4
dist/index.html vendored
View file

@ -20,8 +20,8 @@
<meta name="theme-color" content="#020318" />
<title>evcc</title>
<script type="module" crossorigin src="./assets/index.5a450559.js"></script>
<link rel="stylesheet" href="./assets/index.9eb90bf3.css">
<script type="module" crossorigin src="./assets/index.f014e26a.js"></script>
<link rel="stylesheet" href="./assets/index.c0100ffd.css">
</head>
<body>
<script>