Config UI: add loadpoints (BC) (#18137)

This commit is contained in:
Michael Geers 2025-01-31 12:26:23 +01:00 • committed by GitHub
parent d7fcdf663f
commit 864c2cf599
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
92 changed files with 4643 additions and 1116 deletions

View file

@ -13,7 +13,7 @@ on:
jobs:
clean:
name: Clean
runs-on: ubuntu-latest
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v4
@ -208,14 +208,12 @@ jobs:
- name: Build Go
run: make build
- name: Install Playwright
run: npx playwright install --with-deps chromium
- name: Run tests
run: npx playwright test
# - name: Run tests
# uses: docker://mcr.microsoft.com/playwright:v1.34.3-jammy
# with:
# args: npx playwright test
- uses: actions/upload-artifact@v4
if: ${{ !cancelled() }}
with:

View file

@ -120,7 +120,8 @@ type Authorizer interface {
Authorize(key string) error
}
// PhaseDescriber returns the number of available phases
// PhaseDescriber returns the number of physically connected phases
// Used for vehicles and to limit switch sockets to 1p only
type PhaseDescriber interface {
Phases() int
}

View file

@ -9,7 +9,7 @@ var ErrNotAvailable = errors.New("not available")
var ErrMustRetry = errors.New("must retry")
// ErrSponsorRequired indicates that a sponsor token is required
var ErrSponsorRequired = errors.New("sponsorship required, see https://github.com/evcc-io/evcc#sponsorship")
var ErrSponsorRequired = errors.New("sponsorship required, see https://docs.evcc.io/docs/sponsorship")
// ErrMissingCredentials indicates that user/password are missing
var ErrMissingCredentials = errors.New("missing user/password credentials")

View file

@ -38,7 +38,7 @@ type All struct {
Vehicles []config.Named
Tariffs Tariffs
Site map[string]interface{}
Loadpoints []map[string]interface{}
Loadpoints []config.Named
Circuits []config.Named
}

View file

@ -25,8 +25,9 @@
--evcc-green: #baffcb;
--evcc-dark-green: #0fde41ff;
--evcc-darker-green: #0ba631ff;
--evcc-darker-green-rgb: 11, 166, 49;
--evcc-darkest-green: #076f20ff;
--evcc-darkest-green-rgb: 11, 166, 49;
--evcc-darkest-green-rgb: 7, 111, 32;
--evcc-yellow: #faf000;
--evcc-dark-yellow: #bbb400;
--evcc-orange: #ff9000;
@ -269,10 +270,16 @@ a:hover {
--bs-btn-hover-border-color: var(--bs-gray-medium);
}
.btn-link:hover {
.btn-link:hover,
.btn-link:focus {
color: var(--bs-primary);
}
.btn-link:focus {
outline: var(--bs-focus-ring-width) solid var(--bs-focus-ring-color);
outline-width: var(--bs-focus-ring-width);
}
.dark .btn-outline-secondary {
--bs-btn-color: var(--bs-gray-bright);
--bs-btn-border-color: var(--bs-gray-bright);
@ -364,6 +371,16 @@ a:hover {
transition-timing-function: linear;
}
.modal.fade-left .modal-dialog {
transform: translate(-50px, 0);
}
.modal.fade-right .modal-dialog {
transform: translate(50px, 0);
}
.modal.show .modal-dialog {
transform: none;
}
.modal-header {
padding: 0 0 1rem 0;
border: none;
@ -517,8 +534,13 @@ input::-webkit-datetime-edit {
.form-control {
color: var(--evcc-default-text);
}
.form-control:disabled {
.form-control:disabled,
.form-control:read-only,
.dark .form-control:disabled,
.dark .form-control:read-only {
background-color: var(--bs-secondary-bg);
color: var(--bs-gray-dark);
opacity: 1;
}
.dark .form-control {
@ -633,6 +655,12 @@ html.app .modal-dialog {
color: var(--evcc-darkest-green);
}
.hyphenate {
overflow-wrap: break-word;
word-wrap: break-word;
hyphens: auto;
}
input::-webkit-date-and-time-value {
text-align: left;
}

View file

@ -271,6 +271,7 @@ export default {
this.modal.show();
},
updateTargetTimeLabel: function () {
if (!this.effectivePlanTime) return "";
const targetDate = new Date(this.effectivePlanTime);
this.targetTimeLabel = this.fmtAbsoluteDate(targetDate);
},

View file

@ -0,0 +1,428 @@
<template>
<GenericModal
id="chargerModal"
:title="modalTitle"
data-testid="charger-modal"
:fade="fade"
@open="open"
@close="close"
>
<form ref="form" class="container mx-0 px-0">
<FormRow id="chargerTemplate" :label="$t('config.charger.template')">
<select
id="chargerTemplate"
v-model="templateName"
:disabled="!isNew"
class="form-select w-100"
@change="templateChanged"
>
<option value="">---</option>
<optgroup :label="$t('config.charger.generic')">
<option
v-for="option in genericOptions"
:key="option.name"
:value="option.template"
>
{{ option.name }}
</option>
</optgroup>
<optgroup :label="$t('config.charger.chargers')">
<option
v-for="option in chargerOptions"
:key="option.name"
:value="option.template"
>
{{ option.name }}
</option>
</optgroup>
<optgroup :label="$t('config.charger.switchsocket')">
<option
v-for="option in switchSocketOptions"
:key="option.name"
:value="option.template"
>
{{ option.name }}
</option>
</optgroup>
</select>
</FormRow>
<p v-if="loadingTemplate">Loading ...</p>
<SponsorTokenRequired v-if="sponsorTokenRequired" />
<Markdown v-if="description" :markdown="description" class="my-4" />
<FormRow
v-if="ocppUrl"
id="chargerOcppUrl"
:label="$t('config.charger.ocppLabel')"
:help="$t('config.charger.ocppHelp')"
>
<input
type="text"
class="form-control border border-success bg-transparent"
:value="ocppUrl"
readonly
/>
</FormRow>
<Modbus
v-if="modbus"
v-model:modbus="values.modbus"
v-model:id="values.id"
v-model:host="values.host"
v-model:port="values.port"
v-model:device="values.device"
v-model:baudrate="values.baudrate"
v-model:comset="values.comset"
:defaultId="modbus.ID"
:defaultComset="modbus.Comset"
:defaultBaudrate="modbus.Baudrate"
:defaultPort="modbus.Port"
:capabilities="modbusCapabilities"
/>
<PropertyEntry
v-for="param in normalParams"
:id="`chargerParam${param.Name}`"
:key="param.Name"
v-bind="param"
v-model="values[param.Name]"
/>
<PropertyCollapsible>
<template v-if="advancedParams.length" #advanced>
<PropertyEntry
v-for="param in advancedParams"
:id="`chargerParam${param.Name}`"
:key="param.Name"
v-bind="param"
v-model="values[param.Name]"
/>
</template>
</PropertyCollapsible>
<TestResult
v-if="templateName"
:success="testSuccess"
:failed="testFailed"
:unknown="testUnknown"
:running="testRunning"
:result="testResult"
:error="testError"
@test="testManually"
/>
<div v-if="templateName" class="my-4 d-flex justify-content-between">
<button
v-if="isDeletable"
type="button"
class="btn btn-link text-danger"
@click.prevent="remove"
>
{{ $t("config.general.delete") }}
</button>
<button
v-else
type="button"
class="btn btn-link text-muted"
data-bs-dismiss="modal"
tabindex="0"
>
{{ $t("config.general.cancel") }}
</button>
<button
type="submit"
class="btn btn-primary"
:disabled="testRunning || saving || sponsorTokenRequired"
tabindex="0"
@click.prevent="isNew ? create() : update()"
>
<span
v-if="saving"
class="spinner-border spinner-border-sm"
role="status"
aria-hidden="true"
></span>
{{
testUnknown ? $t("config.general.validateSave") : $t("config.general.save")
}}
</button>
</div>
</form>
</GenericModal>
</template>
<script>
import FormRow from "./FormRow.vue";
import PropertyEntry from "./PropertyEntry.vue";
import PropertyCollapsible from "./PropertyCollapsible.vue";
import TestResult from "./TestResult.vue";
import api from "../../api";
import test from "./mixins/test";
import Modbus from "./Modbus.vue";
import GenericModal from "../GenericModal.vue";
import Markdown from "./Markdown.vue";
import SponsorTokenRequired from "./SponsorTokenRequired.vue";
const initialValues = { type: "template" };
function sleep(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
const CUSTOM_FIELDS = ["modbus"];
export default {
name: "ChargerModal",
components: {
FormRow,
PropertyEntry,
GenericModal,
Modbus,
TestResult,
PropertyCollapsible,
Markdown,
SponsorTokenRequired,
},
mixins: [test],
props: {
id: Number,
name: String,
fade: String,
isSponsor: Boolean,
},
emits: ["added", "updated", "removed", "close"],
data() {
return {
isModalVisible: false,
templates: [],
products: [],
templateName: null,
template: null,
saving: false,
selectedType: null,
loadingTemplate: false,
values: { ...initialValues },
};
},
computed: {
modalTitle() {
if (this.isNew) {
return this.$t(`config.charger.titleAdd`);
}
return this.$t(`config.charger.titleEdit`);
},
chargerOptions() {
return this.products.filter((p) => !p.group);
},
genericOptions() {
return this.products.filter((p) => p.group === "generic");
},
switchSocketOptions() {
return this.products.filter((p) => p.group === "switchsockets");
},
templateParams() {
const params = this.template?.Params || [];
return params.filter((p) => !CUSTOM_FIELDS.includes(p.Name));
},
normalParams() {
return this.templateParams.filter((p) => !p.Advanced);
},
advancedParams() {
return this.templateParams.filter((p) => p.Advanced);
},
modbus() {
const params = this.template?.Params || [];
return params.find((p) => p.Name === "modbus");
},
modbusCapabilities() {
return this.modbus?.Choice || [];
},
ocppUrl() {
const isOcpp =
this.templateParams.some((p) => p.Name === "connector") &&
this.templateParams.some((p) => p.Name === "stationid");
if (isOcpp) {
return `ws://${window.location.hostname}:8887`;
}
return null;
},
modbusDefaults() {
const { ID, Comset, Baudrate, Port } = this.modbus || {};
return {
id: ID,
comset: Comset,
baudrate: Baudrate,
port: Port,
};
},
description() {
return this.template?.Requirements?.Description;
},
sponsorTokenRequired() {
const list = this.template?.Requirements?.EVCC || [];
return list.includes("sponsorship") && !this.isSponsor;
},
apiData() {
return {
template: this.templateName,
...this.modbusDefaults,
...this.values,
};
},
isNew() {
return this.id === undefined;
},
isDeletable() {
return !this.isNew;
},
},
watch: {
isModalVisible(visible) {
if (visible) {
this.templateName = null;
this.reset();
this.loadProducts();
if (this.id !== undefined) {
this.loadConfiguration();
}
}
},
templateName() {
this.loadTemplate();
},
values: {
handler() {
this.resetTest();
},
deep: true,
},
},
methods: {
reset() {
this.values = { ...initialValues };
this.resetTest();
},
async loadConfiguration() {
try {
const charger = (await api.get(`config/devices/charger/${this.id}`)).data.result;
this.values = charger.config;
this.applyDefaultsFromTemplate();
this.templateName = this.values.template;
} catch (e) {
console.error(e);
}
},
async loadProducts() {
if (!this.isModalVisible) {
return;
}
try {
const opts = { params: { lang: this.$i18n?.locale } };
this.products = (await api.get("config/products/charger", opts)).data.result;
} catch (e) {
console.error(e);
}
},
async loadTemplate() {
this.template = null;
this.loadingTemplate = true;
try {
const opts = {
params: {
lang: this.$i18n?.locale,
name: this.templateName,
},
};
const result = await api.get("config/templates/charger", opts);
this.template = result.data.result;
this.applyDefaultsFromTemplate();
} catch (e) {
console.error(e);
}
this.loadingTemplate = false;
},
applyDefaultsFromTemplate() {
const params = this.template?.Params || [];
params
.filter((p) => p.Default && !this.values[p.Name])
.forEach((p) => {
this.values[p.Name] = p.Default;
});
},
async create() {
if (this.testUnknown) {
const success = await this.test(this.testCharger);
if (!success) return;
await sleep(100);
}
this.saving = true;
try {
const response = await api.post("config/devices/charger", this.apiData);
const { name } = response.data.result;
this.$emit("added", name);
this.$emit("updated");
this.close();
} catch (e) {
console.error(e);
alert("create failed");
}
this.saving = false;
},
async testManually() {
await this.test(this.testCharger);
},
async testCharger() {
let url = "config/test/charger";
if (!this.isNew) {
url += `/merge/${this.id}`;
}
return await api.post(url, this.apiData);
},
async update() {
if (this.testUnknown) {
const success = await this.test(this.testCharger);
if (!success) return;
await sleep(250);
}
this.saving = true;
try {
await api.put(`config/devices/charger/${this.id}`, this.apiData);
this.$emit("updated");
this.close();
} catch (e) {
console.error(e);
alert("update failed");
}
this.saving = false;
},
async remove() {
try {
await api.delete(`config/devices/charger/${this.id}`);
this.$emit("removed", this.name);
this.$emit("updated");
this.close();
} catch (e) {
console.error(e);
alert("delete failed");
}
},
open() {
this.isModalVisible = true;
},
close() {
this.$emit("close");
this.isModalVisible = false;
},
templateChanged() {
this.reset();
},
},
};
</script>
<style scoped>
.container {
margin-left: calc(var(--bs-gutter-x) * -0.5);
margin-right: calc(var(--bs-gutter-x) * -0.5);
padding-right: 0;
}
.addButton {
min-height: auto;
}
</style>

View file

@ -154,7 +154,4 @@ export default {
margin-right: calc(var(--bs-gutter-x) * -0.5);
padding-right: 0;
}
.btn-cancel {
margin-left: -0.75rem;
}
</style>

View file

@ -10,6 +10,7 @@
type="button"
class="btn btn-sm btn-outline-secondary position-relative border-0 p-2"
:title="$t('config.main.edit')"
tabindex="0"
@click="$emit('edit')"
>
<shopicon-regular-adjust size="s"></shopicon-regular-adjust>

View file

@ -83,6 +83,9 @@ export default {
case "currentRange":
return `${this.fmtNumber(value[0], 1)} A / ${this.fmtNumber(value[1], 1)} A`;
case "controllable":
case "phases1p3p":
case "singlePhase":
case "enabled":
case "configured":
return value
? this.$t("config.deviceValue.yes")

View file

@ -11,9 +11,11 @@
<slot />
</div>
<div class="form-text evcc-gray">
<div v-if="example">{{ $t("config.form.example") }}: {{ example }}</div>
<div v-if="example" class="hyphenate">
{{ $t("config.form.example") }}: {{ example }}
</div>
<div v-if="help">
<span class="text-gray" v-html="helpHtml"></span>
<span class="text-gray hyphenate" v-html="helpHtml"></span>
<a v-if="link" class="ms-1 text-gray" :href="link" target="_blank">
{{ $t("config.general.docsLink") }}
</a>

View file

@ -7,6 +7,7 @@
class="config-button btn btn-link"
type="button"
:title="$t('config.main.edit')"
tabindex="0"
@click.prevent="openModal('titleModal')"
>
<EditIcon size="xs" />
@ -20,6 +21,7 @@
class="config-button btn btn-link"
type="button"
:title="$t('config.main.edit')"
tabindex="0"
@click.prevent="openModal('passwordModal')"
>
<EditIcon size="xs" />
@ -34,6 +36,7 @@
class="config-button btn btn-link"
type="button"
:title="$t('config.main.edit')"
tabindex="0"
@click.prevent="openModal('globalSettingsModal')"
>
<EditIcon size="xs" />
@ -48,6 +51,7 @@
class="config-button btn btn-link"
type="button"
:title="$t('config.main.edit')"
tabindex="0"
@click.prevent="openModal('globalSettingsModal')"
>
<EditIcon size="xs" />
@ -57,7 +61,7 @@
<strong class="config-label">{{ $t("config.sponsor.title") }} 🧪</strong>
<div class="config-text" :class="sponsorStatus.cssClass">
<span
v-if="sponsorStatus.expiresSoon"
v-if="sponsorStatus.expiresSoon && sponsorStatus.name"
class="d-inline-block me-1 p-1 rounded-circle bg-warning rounded-circle"
></span>
{{ sponsorStatus.name || "---" }}
@ -66,6 +70,7 @@
class="config-button btn btn-link"
type="button"
:title="$t('config.main.edit')"
tabindex="0"
@click.prevent="openModal('sponsorModal')"
>
<EditIcon size="xs" />
@ -78,6 +83,7 @@
class="config-button btn btn-link"
type="button"
:title="$t('config.main.edit')"
tabindex="0"
@click.prevent="openModal('networkModal')"
>
<EditIcon size="xs" />
@ -90,6 +96,7 @@
class="config-button btn btn-link"
type="button"
:title="$t('config.main.edit')"
tabindex="0"
@click.prevent="openModal('controlModal')"
>
<EditIcon size="xs" />
@ -136,10 +143,8 @@ export default {
const sponsor = store.state?.sponsor || {};
const { name, expiresSoon } = sponsor;
let cssClass = "";
if (expiresSoon) {
cssClass = "text-warning";
} else if (name) {
cssClass = "text-primary";
if (name) {
cssClass = expiresSoon ? "text-warning" : "text-primary";
}
return { name, expiresSoon, cssClass };

View file

@ -168,7 +168,4 @@ export default {
margin-right: calc(var(--bs-gutter-x) * -0.5);
padding-right: 0;
}
.btn-cancel {
margin-left: -0.75rem;
}
</style>

View file

@ -0,0 +1,844 @@
<template>
<GenericModal
id="loadpointModal"
:title="modalTitle"
data-testid="loadpoint-modal"
:fade="fade"
@open="open"
@opened="opened"
@close="close"
>
<form ref="form" class="container mx-0 px-0" @submit.prevent="isNew ? create() : update()">
<FormRow
id="loadpointParamTitle"
:label="$t('config.loadpoint.titleLabel')"
:example="$t('config.loadpoint.titleExample')"
>
<PropertyField
id="loadpointParamTitle"
v-model="values.title"
type="String"
class="me-2"
required
/>
</FormRow>
<FormRow
v-if="values.charger"
id="loadpointParamCharger"
:label="$t('config.loadpoint.chargerLabel')"
>
<div class="d-flex">
<PropertyField
id="loadpointParamCharger"
:modelValue="chargerTitle"
type="String"
class="me-2 flex-grow-1"
readonly
required
@click.prevent="editCharger"
/>
<button
class="btn btn-link btn-sm evcc-default-text"
type="button"
@click.prevent="editCharger"
>
<EditIcon />
</button>
</div>
</FormRow>
<div v-else class="d-flex justify-content-end">
<button
class="btn btn-outline-primary"
type="submit"
:disabled="values.title?.length === 0"
@click.prevent="editCharger"
>
{{ $t("config.loadpoint.addCharger") }}
</button>
</div>
<div v-if="values.charger || !isNew">
<FormRow
v-if="values.meter"
id="loadpointParamMeter"
class="mb-6"
:label="$t('config.loadpoint.energyMeterLabel')"
:help="$t('config.loadpoint.energyMeterHelp')"
>
<div class="d-flex">
<PropertyField
id="loadpointParamMeter"
:modelValue="meterTitle"
type="String"
class="me-2 flex-grow-1"
required
readonly
@click="editMeter"
/>
<button
class="btn btn-link btn-sm evcc-default-text"
type="button"
tabindex="0"
@click.prevent="editMeter"
>
<EditIcon />
</button>
</div>
</FormRow>
<p v-else>
<button
class="btn btn-link btn-sm text-gray px-0"
style="margin-top: -1rem"
type="button"
tabindex="0"
@click="editMeter"
>
{{ $t("config.loadpoint.addMeter") }}
</button>
</p>
</div>
<div v-if="values.charger">
<h6>{{ $t("config.loadpoint.chargingTitle") }}</h6>
<FormRow
id="loadpointMode"
:label="$t('config.loadpoint.defaultModeLabel')"
:help="
values.defaultMode === ''
? $t('config.loadpoint.defaultModeHelpKeep')
: $t('config.loadpoint.defaultModeHelp')
"
>
<PropertyField
id="loadpointMode"
v-model="values.defaultMode"
type="Choice"
class="w-100"
required
:choice="[
{ key: '', name: '---' },
{ key: 'off', name: $t('main.mode.off') },
{ key: 'pv', name: $t('main.mode.pv') },
{ key: 'minpv', name: $t('main.mode.minpv') },
{ key: 'now', name: $t('main.mode.now') },
]"
/>
</FormRow>
<FormRow
v-show="showAll"
id="loadpointSolarMode"
:label="$t('config.loadpoint.solarBehaviorLabel')"
:help="
solarMode === 'default'
? $t('config.loadpoint.solarBehaviorDefaultHelp', {
enableDelay: fmtDurationNs(
values.thresholds.enable.delay,
true,
'm'
),
disableDelay: fmtDurationNs(
values.thresholds.disable.delay,
true,
'm'
),
})
: $t('config.loadpoint.solarBehaviorCustomHelp')
"
>
<SelectGroup
id="loadpointSolarMode"
v-model="solarMode"
class="w-100"
:options="[
{ name: $t('config.loadpoint.solarModeMaximum'), value: 'default' },
{ name: $t('config.loadpoint.solarModeCustom'), value: 'custom' },
]"
transparent
equal-width
/>
</FormRow>
<div v-show="solarMode === 'custom'" class="ms-3 mb-5">
<div class="mb-4">
<div class="d-flex flex-wrap flex-sm-nowrap gap-4">
<FormRow
id="loadpointEnableThreshold"
:label="$t('config.loadpoint.thresholdEnableLabel')"
style="margin-bottom: 0 !important"
>
<PropertyField
id="loadpointEnableThreshold"
v-model="values.thresholds.enable.threshold"
type="Float"
unit="W"
size="w-25 w-min-200"
required
/>
</FormRow>
<FormRow
id="loadpointEnableDelay"
:label="$t('config.loadpoint.thresholdEnableDelayLabel')"
style="margin-bottom: 0 !important"
>
<PropertyField
id="loadpointEnableDelay"
v-model="values.thresholds.enable.delay"
type="Duration"
unit="minute"
size="w-25 w-min-200"
required
/>
</FormRow>
</div>
<div class="form-text evcc-gray">
{{
values.thresholds.enable.threshold === 0
? $t("config.loadpoint.thresholdEnableHelpZero", {
delay: fmtDurationNs(
values.thresholds.enable.delay,
true,
"m"
),
})
: values.thresholds.enable.threshold < 0
? $t("config.loadpoint.thresholdEnableHelpNegative", {
surplus: fmtW(
-1 * values.thresholds.enable.threshold,
powerUnit.AUTO
),
delay: fmtDurationNs(
values.thresholds.enable.delay,
true,
"m"
),
})
: $t("config.loadpoint.thresholdEnableHelpInvalid")
}}
</div>
</div>
<div>
<div class="d-flex flex-wrap flex-sm-nowrap gap-4">
<FormRow
id="loadpointDisableThreshold"
:label="$t('config.loadpoint.thresholdDisableLabel')"
style="margin-bottom: 0 !important"
>
<PropertyField
id="loadpointDisableThreshold"
v-model="values.thresholds.disable.threshold"
type="Float"
unit="W"
size="w-25 w-min-200"
required
/>
</FormRow>
<FormRow
id="loadpointDisableDelay"
:label="$t('config.loadpoint.thresholdDisableDelayLabel')"
style="margin-bottom: 0 !important"
>
<PropertyField
id="loadpointDisableDelay"
v-model="values.thresholds.disable.delay"
type="Duration"
unit="minute"
size="w-25 w-min-200"
required
/>
</FormRow>
</div>
<div class="form-text evcc-gray">
{{
values.thresholds.disable.threshold === 0
? $t("config.loadpoint.thresholdDisableHelpZero", {
delay: fmtDurationNs(
values.thresholds.disable.delay,
true,
"m"
),
})
: values.thresholds.disable.threshold > 0
? $t("config.loadpoint.thresholdDisableHelpPositive", {
power: fmtW(
values.thresholds.disable.threshold,
powerUnit.AUTO
),
delay: fmtDurationNs(
values.thresholds.disable.delay,
true,
"m"
),
})
: $t("config.loadpoint.thresholdDisableHelpInvalid")
}}
</div>
</div>
</div>
<FormRow
v-show="showAll"
v-if="showPriority"
id="loadpointParamPriority"
:label="$t('config.loadpoint.priorityLabel')"
:help="$t('config.loadpoint.priorityHelp')"
>
<SelectGroup
id="loadpointParamPriority"
v-model="values.priority"
class="w-100"
:options="priorityOptions"
transparent
/>
</FormRow>
<h6 v-show="showAll">
{{ $t("config.loadpoint.electricalTitle") }}
<small class="text-muted">{{
$t("config.loadpoint.electricalSubtitle")
}}</small>
</h6>
<FormRow
id="chargerPower"
:label="$t('config.loadpoint.chargerTypeLabel')"
:help="
chargerPower === '11kw'
? $t('config.loadpoint.chargerPower11kwHelp')
: chargerPower === '22kw'
? $t('config.loadpoint.chargerPower22kwHelp')
: $t('config.loadpoint.chargerPowerCustomHelp')
"
>
<SelectGroup
id="chargerPower"
v-model="chargerPower"
class="w-100"
:options="[
{ name: $t('config.loadpoint.chargerPower11kw'), value: '11kw' },
{ name: $t('config.loadpoint.chargerPower22kw'), value: '22kw' },
{ name: $t('config.loadpoint.chargerPowerCustom'), value: 'other' },
]"
transparent
/>
</FormRow>
<div v-if="chargerPower === 'other'" class="row ms-3 mb-5">
<FormRow
id="loadpointMinCurrent"
:label="$t('config.loadpoint.minCurrentLabel')"
class="col-sm-6 mb-sm-0"
:help="values.minCurrent < 6 ? $t('config.loadpoint.minCurrentHelp') : null"
>
<PropertyField
id="loadpointMinCurrent"
v-model="values.minCurrent"
type="Float"
unit="A"
size="w-25 w-min-200"
class="me-2"
required
/>
</FormRow>
<FormRow
id="loadpointMaxCurrent"
:label="$t('config.loadpoint.maxCurrentLabel')"
class="col-sm-6 mb-sm-0"
:help="
values.maxCurrent < values.minCurrent
? $t('config.loadpoint.maxCurrentHelp')
: null
"
>
<PropertyField
id="loadpointMaxCurrent"
v-model="values.maxCurrent"
type="Float"
unit="A"
size="w-25 w-min-200"
class="me-2"
required
/>
</FormRow>
</div>
<template v-if="!chargerIsSinglePhase">
<FormRow
v-show="showAll"
v-if="chargerSupports1p3p"
id="loadpointParamPhases"
:label="$t('config.loadpoint.phasesAutomatic')"
:help="$t('config.loadpoint.phasesAutomaticHelp')"
>
</FormRow>
<FormRow
v-else
v-show="showAll"
id="loadpointParamPhases"
:label="$t('config.loadpoint.phasesLabel')"
:help="$t('config.loadpoint.phasesHelp')"
>
<SelectGroup
id="loadpointParamPhases"
v-model="values.phases"
class="w-100"
:options="[
{ value: 1, name: $t('config.loadpoint.phases1p') },
{ value: 3, name: $t('config.loadpoint.phases3p') },
]"
transparent
equal-width
/>
</FormRow>
</template>
<FormRow
v-show="showAll"
v-if="showCircuit"
id="loadpointParamCircuit"
:label="$t('config.loadpoint.circuitLabel')"
:help="$t('config.loadpoint.circuitHelp')"
>
<PropertyField
id="loadpointParamCircuit"
v-model="values.circuit"
type="Choice"
class="me-2"
:choice="circuitOptions"
required
/>
</FormRow>
<h6 v-show="showAll">{{ $t("config.loadpoint.vehiclesTitle") }}</h6>
<div v-if="vehicleOptions.length">
<FormRow
id="loadpointParamVehicle"
:label="$t('config.loadpoint.vehicleLabel')"
:help="
values.vehicle
? $t('config.loadpoint.vehicleHelpDefault')
: $t('config.loadpoint.vehicleHelpAutoDetection')
"
>
<PropertyField
id="loadpointParamVehicle"
v-model="values.vehicle"
type="Choice"
class="me-2"
:choice="allVehicleOptions"
required
/>
</FormRow>
<FormRow
v-show="showAll"
id="loadpointPollMode"
:label="$t('config.loadpoint.pollModeLabel')"
:help="
values.soc.poll.mode === 'charging'
? $t('config.loadpoint.pollModeChargingHelp')
: values.soc.poll.mode === 'connected'
? $t('config.loadpoint.pollModeConnectedHelp')
: values.soc.poll.mode === 'always'
? $t('config.loadpoint.pollModeAlwaysHelp')
: null
"
>
<SelectGroup
id="loadpointPollMode"
v-model="values.soc.poll.mode"
class="w-100"
:options="[
{
value: 'charging',
name: $t('config.loadpoint.pollModeCharging'),
},
{
value: 'connected',
name: $t('config.loadpoint.pollModeConnected'),
},
{ value: 'always', name: $t('config.loadpoint.pollModeAlways') },
]"
transparent
/>
</FormRow>
<FormRow
v-show="showAll"
v-if="values.soc.poll.mode !== 'charging'"
id="loadpointPollInterval"
class="ms-3 mb-5"
:label="$t('config.loadpoint.pollIntervalLabel')"
:help="$t('config.loadpoint.pollIntervalHelp')"
>
<PropertyField
id="loadpointPollInterval"
v-model="values.soc.poll.interval"
type="Duration"
unit="minute"
size="w-25 w-min-200"
class="me-2"
required
/>
</FormRow>
<div v-show="showAll" class="d-flex mb-4">
<input
id="loadpointEstimate"
v-model="values.soc.estimate"
class="form-check-input"
type="checkbox"
/>
<label class="form-check-label ms-2" for="loadpointEstimate">
{{ $t("config.loadpoint.estimateLabel") }}
</label>
</div>
</div>
<div v-else v-show="showAll">
<p class="text-muted">{{ $t("config.loadpoint.noVehicles") }}</p>
</div>
</div>
<button
v-if="!showAll && values.charger"
class="btn btn-link btn-sm text-gray px-0 border-0 d-flex align-items-center mb-2"
type="button"
tabindex="0"
@click="showAllSelected = true"
>
{{ $t("config.loadpoint.showAllSettings") }}
</button>
<div v-if="values.charger" class="mt-5 mb-4 d-flex justify-content-between">
<button
v-if="isDeletable"
type="button"
class="btn btn-link text-danger"
@click.prevent="remove"
>
{{ $t("config.meter.delete") }}
</button>
<button
v-else
type="button"
class="btn btn-link text-muted btn-cancel"
data-bs-dismiss="modal"
>
{{ $t("config.loadpoint.cancel") }}
</button>
<button type="submit" class="btn btn-primary" :disabled="saving">
<span
v-if="saving"
class="spinner-border spinner-border-sm"
role="status"
aria-hidden="true"
></span>
{{ $t("config.loadpoint.save") }}
</button>
</div>
</form>
</GenericModal>
</template>
<script>
import FormRow from "./FormRow.vue";
import PropertyField from "./PropertyField.vue";
import SelectGroup from "../SelectGroup.vue";
import api from "../../api";
import GenericModal from "../GenericModal.vue";
import deepClone from "../../utils/deepClone";
import deepEqual from "../../utils/deepEqual";
import formatter, { POWER_UNIT } from "../../mixins/formatter";
import EditIcon from "../MaterialIcon/Edit.vue";
const nsPerMin = 60 * 1e9;
const defaultValues = {
title: "",
phases: 3,
minCurrent: 6,
maxCurrent: 16,
priority: 0,
defaultMode: "",
thresholds: {
enable: { delay: 1 * nsPerMin, threshold: 0 },
disable: { delay: 3 * nsPerMin, threshold: 0 },
},
soc: {
poll: { mode: "charging", interval: 60 * nsPerMin },
estimate: false,
},
vehicle: "",
charger: "",
circuit: "",
meter: "",
};
const defaultThresholds = {
enable: { delay: 1 * nsPerMin, threshold: 0 },
disable: { delay: 3 * nsPerMin, threshold: 0 },
};
export default {
name: "LoadpointModal",
components: { FormRow, PropertyField, GenericModal, SelectGroup, EditIcon },
mixins: [formatter],
props: {
id: Number,
name: String,
vehicleOptions: { type: Array, default: () => [] },
loadpointCount: Number,
fade: String,
chargers: { type: Array, default: () => [] },
chargerValues: { type: Object, default: () => {} },
meters: { type: Array, default: () => [] },
circuits: { type: Array, default: () => [] },
},
emits: ["updated", "openMeterModal", "openChargerModal", "close", "opened"],
data() {
return {
isModalVisible: false,
saving: false,
selectedType: null,
values: deepClone(defaultValues),
chargerPower: "11kw",
solarMode: "default",
tab: "solar",
showAllSelected: false,
powerUnit: POWER_UNIT,
};
},
computed: {
modalTitle() {
if (this.isNew) {
return this.$t(`config.loadpoint.titleAdd`);
}
return this.$t(`config.loadpoint.titleEdit`);
},
isNew() {
return this.id === undefined;
},
showAll() {
return !this.isNew || this.showAllSelected;
},
charger() {
return this.chargers.find((c) => c.name === this.values.charger);
},
chargerTitle() {
if (!this.charger) return "";
const title = this.charger.config?.template || "unknown";
return `${title} [${this.values.charger}]`;
},
chargerStatus() {
if (!this.chargerValues || !this.values.charger) {
return {};
}
return this.chargerValues[this.values.charger] || {};
},
chargerSupports1p3p() {
return this.chargerStatus.phases1p3p?.value || false;
},
chargerIsSinglePhase() {
return this.chargerStatus.singlePhase?.value || false;
},
meterTitle() {
const name = this.values.meter;
if (!name) return "";
const meter = this.meters.find((m) => m.name === name);
const title = meter?.config?.template || "unknown";
return `${title} [${name}]`;
},
isDeletable() {
return !this.isNew;
},
showPriority() {
return this.priorityOptions.length > 1;
},
priorityOptions() {
const maxPriority = this.loadpointCount + (this.isNew ? 1 : 0);
const result = Array.from({ length: maxPriority }, (_, i) => ({
value: i,
name: `${i}`,
}));
return result;
},
showCircuit() {
return this.circuits.length > 0;
},
circuitOptions() {
const options = this.circuits.map((c) => ({
key: c.name,
name: `${c.config?.title || ""} [${c.name}]`.trim(),
}));
return [{ key: "", name: "unassigned" }, ...options];
},
allVehicleOptions() {
return [
{ key: "", name: this.$t("config.loadpoint.vehicleAutoDetection") },
{ key: null, name: null },
...this.vehicleOptions,
];
},
},
watch: {
isModalVisible(visible) {
if (visible) {
if (this.values?.id !== this.id) {
// loadpoint changed
this.reset();
if (this.id) {
this.loadConfiguration();
}
}
}
},
chargerPower(value) {
if (value === "11kw") {
this.values.minCurrent = 6;
this.values.maxCurrent = 16;
} else if (value === "22kw") {
this.values.minCurrent = 6;
this.values.maxCurrent = 32;
}
},
solarMode(value) {
if (value === "default") {
this.values.thresholds = deepClone(defaultThresholds);
}
},
chargerSupports1p3p() {
this.updatePhases();
},
chargerIsSinglePhase() {
this.updatePhases();
},
},
methods: {
reset() {
this.values = deepClone(defaultValues);
this.updatePhases();
},
async loadConfiguration() {
try {
const res = await api.get(`config/loadpoints/${this.id}`);
this.values = deepClone(res.data.result);
this.updateChargerPower();
this.updateSolarMode();
this.updatePhases();
} catch (e) {
console.error(e);
}
},
async update() {
this.saving = true;
try {
const values = deepClone(this.values);
await api.put(`config/loadpoints/${this.id}`, values);
this.$emit("updated");
this.close();
} catch (e) {
console.error(e);
alert("update failed");
}
this.saving = false;
},
async remove() {
try {
await api.delete(`config/loadpoints/${this.id}`);
this.$emit("updated");
this.close();
} catch (e) {
console.error(e);
alert("delete failed");
}
},
async create() {
this.saving = true;
try {
await api.post("config/loadpoints", this.values);
this.reset();
this.$emit("updated");
this.close();
} catch (e) {
console.error(e);
const error = e.response?.data?.error;
alert(`failed to create loadpoint: ${error}`);
}
this.saving = false;
},
open() {
this.isModalVisible = true;
},
opened() {
this.$emit("opened");
},
close() {
this.$emit("close");
this.showAllSelected = false;
this.isModalVisible = false;
},
editCharger() {
this.$emit("openChargerModal", this.values.charger);
},
editMeter() {
this.$emit("openMeterModal", this.values.meter);
},
// called externally
setMeter(meter) {
this.values.meter = meter;
},
// called externally
setCharger(charger) {
this.values.charger = charger;
},
updateChargerPower() {
const { minCurrent, maxCurrent } = this.values;
if (minCurrent === 6 && maxCurrent === 16) {
this.chargerPower = "11kw";
} else if (minCurrent === 6 && maxCurrent === 32) {
this.chargerPower = "22kw";
} else {
this.chargerPower = "other";
}
},
updateSolarMode() {
const { thresholds } = this.values;
if (deepEqual(thresholds, defaultThresholds)) {
this.solarMode = "default";
} else {
this.solarMode = "custom";
}
},
updatePhases() {
const { phases } = this.values;
if (this.chargerIsSinglePhase) {
this.values.phases = 1;
return;
}
if (this.chargerSupports1p3p && this.isNew) {
this.values.phases = 0; // automatic
return;
}
if (!this.chargerSupports1p3p && phases === 0) {
this.values.phases = 3; // no automatic switching, default to 3-phase
return;
}
},
},
};
</script>
<style scoped>
.container {
margin-left: calc(var(--bs-gutter-x) * -0.5);
margin-right: calc(var(--bs-gutter-x) * -0.5);
padding-right: 0;
}
.addButton {
min-height: auto;
}
h6 {
margin-top: 4rem;
}
</style>

View file

@ -1,36 +1,19 @@
<template>
<Teleport to="body">
<div
<GenericModal
id="meterModal"
ref="modal"
class="modal fade text-dark"
data-bs-backdrop="true"
tabindex="-1"
role="dialog"
aria-hidden="true"
:title="modalTitle"
data-testid="meter-modal"
:fade="fade"
@open="open"
@close="close"
>
<div class="modal-dialog modal-dialog-centered" role="document">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">
{{ modalTitle }}
</h5>
<button
type="button"
class="btn-close"
data-bs-dismiss="modal"
aria-label="Close"
></button>
</div>
<div class="modal-body">
<div v-if="!meterType">
<AddDeviceButton
<NewDeviceButton
title="Add solar meter"
class="mb-4 addButton"
@click="selectType('pv')"
/>
<AddDeviceButton
<NewDeviceButton
title="Add battery meter"
class="addButton"
@click="selectType('battery')"
@ -52,9 +35,7 @@
>
{{ option.name }}
</option>
<option v-if="genericOptions.length" disabled>
──────────
</option>
<option v-if="genericOptions.length" disabled>──────────</option>
<option
v-for="option in templateOptions"
:key="option.name"
@ -117,22 +98,25 @@
v-if="isDeletable"
type="button"
class="btn btn-link text-danger"
tabindex="0"
@click.prevent="remove"
>
{{ $t("config.meter.delete") }}
{{ $t("config.general.delete") }}
</button>
<button
v-else
type="button"
class="btn btn-link text-muted"
data-bs-dismiss="modal"
tabindex="0"
>
{{ $t("config.meter.cancel") }}
{{ $t("config.general.cancel") }}
</button>
<button
type="submit"
class="btn btn-primary"
:disabled="testRunning || saving"
tabindex="0"
@click.prevent="isNew ? create() : update()"
>
<span
@ -142,18 +126,12 @@
aria-hidden="true"
></span>
{{
testUnknown
? $t("config.meter.validateSave")
: $t("config.meter.save")
testUnknown ? $t("config.general.validateSave") : $t("config.general.save")
}}
</button>
</div>
</form>
</div>
</div>
</div>
</div>
</Teleport>
</GenericModal>
</template>
<script>
@ -163,8 +141,9 @@ import PropertyCollapsible from "./PropertyCollapsible.vue";
import TestResult from "./TestResult.vue";
import api from "../../api";
import test from "./mixins/test";
import AddDeviceButton from "./AddDeviceButton.vue";
import NewDeviceButton from "./NewDeviceButton.vue";
import Modbus from "./Modbus.vue";
import GenericModal from "../GenericModal.vue";
import Markdown from "./Markdown.vue";
const initialValues = { type: "template" };
@ -180,9 +159,10 @@ export default {
components: {
FormRow,
PropertyEntry,
GenericModal,
Modbus,
TestResult,
AddDeviceButton,
NewDeviceButton,
PropertyCollapsible,
Markdown,
},
@ -191,8 +171,9 @@ export default {
id: Number,
name: String,
type: String,
fade: String,
},
emits: ["added", "updated", "removed"],
emits: ["added", "updated", "removed", "close"],
data() {
return {
isModalVisible: false,
@ -279,9 +260,9 @@ export default {
watch: {
isModalVisible(visible) {
if (visible) {
this.templateName = null;
this.selectedType = null;
this.reset();
this.resetTest();
this.loadProducts();
if (this.id !== undefined) {
this.loadConfiguration();
@ -301,14 +282,6 @@ export default {
deep: true,
},
},
mounted() {
this.$refs.modal.addEventListener("show.bs.modal", this.modalVisible);
this.$refs.modal.addEventListener("hide.bs.modal", this.modalInvisible);
},
unmounted() {
this.$refs.modal?.removeEventListener("show.bs.modal", this.modalVisible);
this.$refs.modal?.removeEventListener("hide.bs.modal", this.modalInvisible);
},
methods: {
reset() {
this.values = { ...initialValues };
@ -332,6 +305,7 @@ export default {
const opts = {
params: {
usage: this.meterType,
lang: this.$i18n?.locale,
},
};
this.products = (await api.get("config/products/meter", opts)).data.result;
@ -340,6 +314,7 @@ export default {
}
},
async loadTemplate() {
if (!this.templateName) return;
this.template = null;
this.loadingTemplate = true;
try {
@ -377,7 +352,7 @@ export default {
const { name } = response.data.result;
this.$emit("added", this.meterType, name);
this.$emit("updated");
this.modalInvisible();
this.close();
} catch (e) {
console.error(e);
alert("create failed");
@ -404,7 +379,7 @@ export default {
try {
await api.put(`config/devices/meter/${this.id}`, this.apiData);
this.$emit("updated");
this.modalInvisible();
this.close();
} catch (e) {
console.error(e);
alert("update failed");
@ -416,16 +391,17 @@ export default {
await api.delete(`config/devices/meter/${this.id}`);
this.$emit("removed", this.meterType, this.name);
this.$emit("updated");
this.modalInvisible();
this.close();
} catch (e) {
console.error(e);
alert("delete failed");
}
},
modalVisible() {
open() {
this.isModalVisible = true;
},
modalInvisible() {
close() {
this.$emit("close");
this.isModalVisible = false;
},
selectType(type) {

View file

@ -17,6 +17,7 @@
class="btn-check"
name="modbusConnection"
value="tcpip"
tabindex="0"
autocomplete="off"
/>
<label class="btn btn-outline-primary" for="modbusTcpIp">Network</label>
@ -27,6 +28,7 @@
class="btn-check"
name="modbusConnection"
value="serial"
tabindex="0"
autocomplete="off"
/>
<label class="btn btn-outline-primary" for="modbusSerial">Serial / USB</label>
@ -84,6 +86,7 @@
class="btn-check"
name="modbusProtocol"
value="tcp"
tabindex="0"
autocomplete="off"
/>
<label class="btn btn-outline-primary" for="modbusTcp">TCP</label>
@ -94,6 +97,7 @@
class="btn-check"
name="modbusProtocol"
value="rtu"
tabindex="0"
autocomplete="off"
/>
<label class="btn btn-outline-primary" for="modbusRtu">RTU</label>

View file

@ -21,6 +21,7 @@
type="radio"
class="btn-check"
name="networkSchema"
tabindex="0"
value="http"
autocomplete="off"
/>
@ -31,6 +32,7 @@
type="radio"
class="btn-check"
name="networkSchema"
tabindex="0"
value="https"
autocomplete="off"
/>

View file

@ -1,6 +1,10 @@
<template>
<li class="root">
<button class="d-flex align-items-center justify-content-center p-3" @click="$emit('add')">
<button
class="d-flex align-items-center justify-content-center p-3"
tabindex="0"
@click="$emit('click')"
>
<shopicon-regular-plus class="me-1"></shopicon-regular-plus>
{{ title }}
</button>
@ -11,11 +15,11 @@
import "@h2d2/shopicons/es/regular/plus";
export default {
name: "AddDeviceButton",
name: "NewDeviceButton",
props: {
title: String,
},
emits: ["add"],
emits: ["click"],
};
</script>
@ -30,7 +34,8 @@ export default {
padding: 1rem 1rem 0.5rem;
transition: border-color var(--evcc-transition-fast) linear;
}
.root:hover {
.root:hover,
.root:focus-within {
border-color: var(--evcc-default-text);
color: var(--evcc-default-text);
}
@ -42,4 +47,7 @@ button {
width: 100%;
color: inherit;
}
button:focus {
outline: none;
}
</style>

View file

@ -4,6 +4,7 @@
class="btn btn-link btn-sm text-gray px-0 border-0 d-flex align-items-center mb-2"
:class="open ? 'text-primary' : ''"
type="button"
tabindex="0"
@click="toggle"
>
<span v-if="open">{{ $t("config.general.hideAdvancedSettings") }}</span>

View file

@ -147,15 +147,15 @@ export default {
return null;
},
unitValue() {
if (this.type === "Duration") {
return this.fmtDurationUnit(this.value, this.unit);
}
if (this.unit) {
return this.unit;
}
if (this.property === "capacity") {
return "kWh";
}
if (this.type === "Duration") {
return this.fmtSecondUnit(this.value);
}
return null;
},
icons() {
@ -173,6 +173,9 @@ export default {
select() {
return this.choice.length > 0;
},
durationFactor() {
return this.unit === "minute" ? 60 : 1;
},
selectOptions() {
// If the valid values are already in the correct format, return them
if (typeof this.choice[0] === "object") {
@ -211,7 +214,7 @@ export default {
}
if (this.type === "Duration" && typeof this.modelValue === "number") {
return this.modelValue / NS_PER_SECOND;
return this.modelValue / this.durationFactor / NS_PER_SECOND;
}
return this.modelValue;
@ -228,7 +231,7 @@ export default {
}
if (this.type === "Duration" && typeof newValue === "number") {
newValue = newValue * NS_PER_SECOND;
newValue = newValue * this.durationFactor * NS_PER_SECOND;
}
this.$emit("update:modelValue", newValue);

View file

@ -18,14 +18,23 @@
<div class="mt-4 mb-3">
<Sponsor v-bind="sponsor" />
</div>
<div v-if="!showForm" class="d-flex gap-1 justify-content-between flex-wrap">
<button
v-if="!showForm"
type="button"
class="btn btn-link text-muted btn-form"
class="btn btn-link text-muted text-truncate"
@click="showForm = !showForm"
>
{{ sponsorTokenLabel }}
</button>
<a
v-if="!hasToken"
class="btn btn-link text-muted text-truncate"
:href="trialTokenLink"
target="_blank"
>
{{ $t("config.sponsor.trialToken") }}
</a>
</div>
<div v-else>
<hr />
<FormRow
@ -57,6 +66,7 @@ import FormRow from "./FormRow.vue";
import Sponsor, { VICTRON_DEVICE } from "../Sponsor.vue";
import SponsorTokenExpires from "../SponsorTokenExpires.vue";
import store from "../../store";
import { docsPrefix } from "../../i18n";
export default {
name: "SponsorModal",
@ -78,6 +88,9 @@ export default {
? this.$t("config.sponsor.changeToken")
: this.$t("config.sponsor.addToken");
},
trialTokenLink() {
return `${docsPrefix()}/docs/sponsorship#trial`;
},
},
methods: {
transformReadValues() {
@ -90,7 +103,4 @@ export default {
textarea {
font-family: var(--bs-font-monospace);
}
.btn-form {
margin-left: -0.75rem;
}
</style>

View file

@ -0,0 +1,21 @@
<template>
<div class="alert alert-warning my-4">
{{ $t("config.sponsor.tokenRequired") }}
<a href="#" role="button" class="text-warning" @click.prevent="openModal">
{{ $t("config.sponsor.tokenRequiredLearnMore") }}
</a>
</div>
</template>
<script>
import Modal from "bootstrap/js/dist/modal";
export default {
name: "SponsorTokenRequired",
methods: {
openModal() {
Modal.getOrCreateInstance(document.getElementById("sponsorModal")).show();
},
},
};
</script>

View file

@ -18,7 +18,7 @@
role="status"
aria-hidden="true"
></span>
<a v-else href="#" class="alert-link" @click.prevent="$emit('test')">
<a v-else href="#" class="alert-link" tabindex="0" @click.prevent="$emit('test')">
{{ $t("config.validation.validate") }}
</a>
</div>

View file

@ -1,29 +1,11 @@
<template>
<Teleport to="body">
<div
<GenericModal
id="vehicleModal"
ref="modal"
class="modal fade text-dark"
data-bs-backdrop="true"
tabindex="-1"
role="dialog"
aria-hidden="true"
:title="$t(`config.vehicle.${isNew ? 'titleAdd' : 'titleEdit'}`)"
data-testid="vehicle-modal"
@open="open"
@closed="closed"
>
<div class="modal-dialog modal-dialog-centered" role="document">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">
{{ $t(`config.vehicle.${isNew ? "titleAdd" : "titleEdit"}`) }}
</h5>
<button
type="button"
class="btn-close"
data-bs-dismiss="modal"
aria-label="Close"
></button>
</div>
<div class="modal-body">
<form ref="form" class="container mx-0 px-0">
<FormRow id="vehicleTemplate" :label="$t('config.vehicle.template')">
<select
@ -120,8 +102,8 @@
{ name: '2-phases', value: '2' },
{ name: '3-phases', value: undefined },
]"
transparent
equal-width
transparent
/>
</FormRow>
<div class="row mb-3">
@ -144,7 +126,6 @@
class="me-2"
/>
</FormRow>
<FormRow
id="vehicleParamMaxCurrent"
label="Maximum current"
@ -243,18 +224,12 @@
aria-hidden="true"
></span>
{{
testUnknown
? $t("config.vehicle.validateSave")
: $t("config.vehicle.save")
testUnknown ? $t("config.vehicle.validateSave") : $t("config.vehicle.save")
}}
</button>
</div>
</form>
</div>
</div>
</div>
</div>
</Teleport>
</GenericModal>
</template>
<script>
@ -264,6 +239,7 @@ import TestResult from "./TestResult.vue";
import SelectGroup from "../SelectGroup.vue";
import PropertyEntry from "./PropertyEntry.vue";
import PropertyCollapsible from "./PropertyCollapsible.vue";
import GenericModal from "../GenericModal.vue";
import Markdown from "./Markdown.vue";
import api from "../../api";
import test from "./mixins/test";
@ -282,6 +258,7 @@ export default {
FormRow,
PropertyField,
TestResult,
GenericModal,
SelectGroup,
PropertyCollapsible,
PropertyEntry,
@ -385,14 +362,6 @@ export default {
deep: true,
},
},
mounted() {
this.$refs.modal.addEventListener("show.bs.modal", this.modalVisible);
this.$refs.modal.addEventListener("hide.bs.modal", this.modalInvisible);
},
unmounted() {
this.$refs.modal?.removeEventListener("show.bs.modal", this.modalVisible);
this.$refs.modal?.removeEventListener("hide.bs.modal", this.modalInvisible);
},
methods: {
reset() {
this.values = { ...initialValues };
@ -410,7 +379,8 @@ export default {
},
async loadProducts() {
try {
this.products = (await api.get("config/products/vehicle")).data.result;
const opts = { params: { lang: this.$i18n?.locale } };
this.products = (await api.get("config/products/vehicle", opts)).data.result;
} catch (e) {
console.error(e);
}
@ -450,7 +420,7 @@ export default {
try {
await api.post("config/devices/vehicle", this.apiData);
this.$emit("vehicle-changed");
this.modalInvisible();
this.closed();
} catch (e) {
console.error(e);
alert("create failed");
@ -477,7 +447,7 @@ export default {
try {
await api.put(`config/devices/vehicle/${this.id}`, this.apiData);
this.$emit("vehicle-changed");
this.modalInvisible();
this.closed();
} catch (e) {
console.error(e);
alert("update failed");
@ -488,16 +458,16 @@ export default {
try {
await api.delete(`config/devices/vehicle/${this.id}`);
this.$emit("vehicle-changed");
this.modalInvisible();
this.closed();
} catch (e) {
console.error(e);
alert("delete failed");
}
},
modalVisible() {
open() {
this.isModalVisible = true;
},
modalInvisible() {
closed() {
this.isModalVisible = false;
},
templateChanged() {
@ -513,4 +483,3 @@ export default {
padding-right: 0;
}
</style>
import type MarkdownVue from "./Markdown.vue";

View file

@ -138,8 +138,4 @@ export default {
margin: 0;
}
}
.btn-cancel {
margin-left: -0.75rem;
}
</style>

View file

@ -3,8 +3,7 @@
<div
:id="id"
ref="modal"
class="modal fade text-dark"
:class="sizeClass"
:class="classes"
tabindex="-1"
role="dialog"
aria-hidden="true"
@ -26,7 +25,7 @@
aria-label="Close"
></button>
</div>
<div class="modal-body">
<div ref="modalBody" class="modal-body">
<slot />
</div>
</div>
@ -45,28 +44,69 @@ export default {
title: String,
dataTestid: String,
uncloseable: Boolean,
fade: String,
size: String,
},
emits: ["open", "closed"],
emits: ["open", "opened", "close", "closed"],
data: function () {
return {
isModalVisible: false,
};
},
computed: {
classes() {
return [
"modal",
"fade",
"text-dark",
{ show: this.isModalVisible },
this.sizeClass,
this.fadeClass,
];
},
sizeClass() {
return this.size ? `modal-${this.size}` : "";
},
fadeClass() {
if (this.fade) {
return `fade-${this.fade}`;
}
return "";
},
},
mounted() {
this.$refs.modal.addEventListener("show.bs.modal", this.handleShow);
this.$refs.modal.addEventListener("shown.bs.modal", this.handleShown);
this.$refs.modal.addEventListener("hide.bs.modal", this.handleHide);
this.$refs.modal.addEventListener("hidden.bs.modal", this.handleHidden);
},
unmounted() {
this.$refs.modal?.removeEventListener("show.bs.modal", this.handleShow);
this.$refs.modal?.removeEventListener("shown.bs.modal", this.handleShown);
this.$refs.modal?.removeEventListener("hide.bs.modal", this.handleHide);
this.$refs.modal?.removeEventListener("hidden.bs.modal", this.handleHidden);
},
methods: {
handleShow() {
this.$emit("open");
},
handleShown() {
this.$emit("opened");
// focus first input or select
this.$nextTick(() => {
const firstInput = this.$refs.modalBody.querySelector("input, select, button");
if (firstInput) {
firstInput.focus();
}
});
this.isModalVisible = true;
},
handleHide() {
this.$emit("close");
},
handleHidden() {
this.$emit("closed");
this.isModalVisible = false;
},
open() {
Modal.getOrCreateInstance(this.$refs.modal).show();

View file

@ -184,7 +184,7 @@ export default {
phasesConfigured: Number,
phasesActive: Number,
chargerPhases1p3p: Boolean,
chargerPhysicalPhases: Number,
chargerSinglePhase: Boolean,
minCurrent: Number,
maxCurrent: Number,
chargeCurrent: Number,

View file

@ -185,7 +185,7 @@ export default {
phasesConfigured: Number,
phasesActive: Number,
chargerPhases1p3p: Boolean,
chargerPhysicalPhases: Number,
chargerSinglePhase: Boolean,
batteryBoost: Boolean,
batteryBoostAvailable: Boolean,
mode: String,
@ -215,8 +215,7 @@ export default {
},
computed: {
phasesOptions() {
if (this.chargerPhysicalPhases == 1) {
// known fixed phase configuration, no settings required
if (this.chargerSinglePhase) {
return [];
}
if (this.chargerPhases1p3p) {

View file

@ -0,0 +1,14 @@
<template>
<svg :style="svgStyle" viewBox="0 0 24 24">
<path fill="currentColor" d="M11 13H5v-2h6V5h2v6h6v2h-6v6h-2z" />
</svg>
</template>
<script>
import icon from "../../mixins/icon";
export default {
name: "Add",
mixins: [icon],
};
</script>

View file

@ -0,0 +1,17 @@
<template>
<svg :style="svgStyle" viewBox="0 0 24 24">
<path
fill="currentColor"
d="M6 19h6V5H6Zm2-1l4-7h-2V6l-4 7.5h2Zm-4 3V5q0-.825.588-1.413Q5.175 3 6 3h6q.825 0 1.413.587Q14 4.175 14 5v7h1q.825 0 1.413.587Q17 13.175 17 14v4.5q0 .425.288.712q.287.288.712.288t.712-.288Q19 18.925 19 18.5v-7.2q-.225.125-.475.162q-.25.038-.525.038q-1.05 0-1.775-.725Q15.5 10.05 15.5 9q0-.8.438-1.438q.437-.637 1.162-.912L15 4.55l1.05-1.05l3.7 3.6q.375.375.562.875q.188.5.188 1.025v9.5q0 1.05-.725 1.775Q19.05 21 18 21q-1.05 0-1.775-.725q-.725-.725-.725-1.775v-5H14V21Zm8-2H6h6Zm6-9q.425 0 .712-.288Q19 9.425 19 9t-.288-.713Q18.425 8 18 8t-.712.287Q17 8.575 17 9t.288.712Q17.575 10 18 10Z"
/>
</svg>
</template>
<script>
import icon from "../../mixins/icon";
export default {
name: "Loadpoint",
mixins: [icon],
};
</script>

View file

@ -6,6 +6,7 @@
type="button"
class="btn flex-grow-1 flex-shrink-1"
:class="{ active: isActive(m) }"
tabindex="0"
@click="setTargetMode(m)"
>
{{ label(m) }}
@ -73,6 +74,10 @@ export default {
.btn:hover {
color: var(--evcc-gray);
}
.btn:focus {
outline: var(--bs-focus-ring-width) solid var(--bs-focus-ring-color);
outline-width: var(--bs-focus-ring-width);
}
.btn.active {
color: var(--evcc-background);
background: var(--evcc-default-text);

View file

@ -12,6 +12,7 @@
class="btn btn-secondary me-2 btn-sm d-flex align-items-center"
type="button"
disabled
tabindex="0"
>
<span
class="spinner-border spinner-border-sm m-1 me-2"
@ -30,6 +31,7 @@
<button
class="btn btn-secondary me-2 btn-sm d-flex align-items-center"
type="button"
tabindex="0"
@click="restart"
>
<Sync class="restart me-2" />
@ -62,6 +64,7 @@
type="button"
class="btn-close mt-1"
aria-label="Close"
tabindex="0"
@click="dismissed = true"
></button>
</div>

View file

@ -8,6 +8,8 @@
class="btn btn-sm flex-grow-1 flex-shrink-1"
:class="{ active: option.value === modelValue, 'btn--equal': equalWidth }"
:disabled="option.disabled"
:data-testid="`${id}-${option.value}`"
tabindex="0"
@click="$emit('update:modelValue', option.value)"
>
{{ option.name }}
@ -56,6 +58,10 @@ export default {
.btn:hover {
color: var(--evcc-gray);
}
.btn:focus {
outline: var(--bs-focus-ring-width) solid var(--bs-focus-ring-color);
outline-width: var(--bs-focus-ring-width);
}
.btn.active {
color: var(--evcc-background);
background: var(--evcc-default-text);

View file

@ -3,7 +3,9 @@
<div class="container px-4 top-area">
<div class="d-flex justify-content-between align-items-center my-3 my-md-4">
<h1 class="d-block my-0">
<span v-if="!isInitialSetup">
{{ siteTitle || "evcc" }}
</span>
</h1>
<div class="d-flex">
<Notifications
@ -14,12 +16,31 @@
<TopNavigation v-bind="topNavigation" />
</div>
</div>
<Energyflow v-bind="energyflow" />
<Energyflow v-if="loadpoints.length > 0" v-bind="energyflow" />
</div>
<div class="d-flex flex-column justify-content-between content-area">
<div v-if="fatal" class="flex-grow-1 align-items-center d-flex justify-content-center">
<h1 class="mb-5 text-gray fs-4">{{ $t("startupError.title") }}</h1>
</div>
<div
v-else-if="isInitialSetup"
class="flex-grow-1 d-flex align-items-center justify-content-center p-3"
>
<div
class="welcome d-flex align-items-center flex-column justify-content-center text-center"
>
<h1 class="mb-0 fs-4 d-flex align-items-center gap-2">
{{ $t("main.welcome") }}
</h1>
<WelcomeIcons class="welcome-icons" />
<router-link
class="btn btn-lg btn-outline-primary configure-button"
to="/config"
>
{{ $t("main.startConfiguration") }}
</router-link>
</div>
</div>
<Loadpoints
v-else
class="mt-1 mt-sm-2 flex-grow-1"
@ -48,6 +69,7 @@ import Loadpoints from "./Loadpoints.vue";
import Footer from "./Footer.vue";
import formatter from "../mixins/formatter";
import collector from "../mixins/collector";
import WelcomeIcons from "./WelcomeIcons.vue";
export default {
name: "Site",
@ -57,6 +79,7 @@ export default {
Footer,
Notifications,
TopNavigation,
WelcomeIcons,
},
mixins: [formatter, collector],
props: {
@ -143,6 +166,9 @@ export default {
// work in progess
return false;
},
isInitialSetup: function () {
return this.loadpoints.length === 0;
},
footer: function () {
return {
version: {
@ -177,4 +203,30 @@ export default {
}
.fatal {
}
.configure-button:not(:active):not(:hover),
.welcome-icons {
animation: colorTransition 10s infinite alternate;
animation-timing-function: ease-in-out;
}
@keyframes colorTransition {
0% {
color: var(--evcc-accent1);
border-color: var(--evcc-accent1);
}
50% {
color: var(--evcc-accent2);
border-color: var(--evcc-accent2);
}
100% {
color: var(--evcc-accent3);
border-color: var(--evcc-accent3);
}
}
.welcome {
background-color: var(--evcc-box);
padding: 4rem;
border-radius: 2rem;
}
</style>

View file

@ -0,0 +1,121 @@
<template>
<div class="d-flex justify-content-center gap-4 my-5 color-transition">
<transition name="fade" mode="out-in">
<component :is="leftIcon" :key="leftIconIndex" class="animated-icon"></component>
</transition>
<transition name="fade" mode="out-in">
<component :is="centerIcon" :key="centerIconIndex" class="animated-icon"></component>
</transition>
<transition name="fade" mode="out-in">
<component :is="rightIcon" :key="rightIconIndex" class="animated-icon"></component>
</transition>
</div>
</template>
<script>
import "@h2d2/shopicons/es/regular/batterythreequarters";
import "@h2d2/shopicons/es/regular/cablecharge";
import "@h2d2/shopicons/es/regular/car3";
import "@h2d2/shopicons/es/regular/car1";
import "@h2d2/shopicons/es/regular/eco1";
import "@h2d2/shopicons/es/regular/heart";
import "@h2d2/shopicons/es/regular/home";
import "@h2d2/shopicons/es/regular/lightning";
import "@h2d2/shopicons/es/regular/moonstars";
import "@h2d2/shopicons/es/regular/powersupply";
import "@h2d2/shopicons/es/regular/receivepayment";
import "@h2d2/shopicons/es/regular/sun";
import "@h2d2/shopicons/es/regular/clock";
import BatteryBoost from "./MaterialIcon/BatteryBoost.vue";
import SunUp from "./MaterialIcon/SunUp.vue";
import DynamicPrice from "./MaterialIcon/DynamicPrice.vue";
import { markRaw } from "vue";
export default {
data() {
return {
leftIconIndex: 2,
centerIconIndex: 6,
rightIconIndex: 10,
updatePosition: 0,
icons: [
"shopicon-regular-batterythreequarters",
"shopicon-regular-cablecharge",
"shopicon-regular-car3",
"shopicon-regular-eco1",
"shopicon-regular-heart",
"shopicon-regular-home",
"shopicon-regular-lightning",
"shopicon-regular-moonstars",
"shopicon-regular-powersupply",
"shopicon-regular-receivepayment",
"shopicon-regular-sun",
"shopicon-regular-clock",
"shopicon-regular-car1",
markRaw(BatteryBoost),
markRaw(SunUp),
markRaw(DynamicPrice),
],
interval: null,
};
},
computed: {
leftIcon() {
return this.icons[this.leftIconIndex];
},
centerIcon() {
return this.icons[this.centerIconIndex];
},
rightIcon() {
return this.icons[this.rightIconIndex];
},
},
mounted() {
this.interval = setInterval(this.rotateIcons, 3000);
},
unmounted() {
clearInterval(this.interval);
},
methods: {
getRandomIcon(excludeIndices) {
const availableIndices = Array.from(Array(this.icons.length).keys()).filter(
(i) => !excludeIndices.includes(i)
);
const randomIndex = Math.floor(Math.random() * availableIndices.length);
return availableIndices[randomIndex];
},
rotateIcons() {
const excludeIndices = [this.leftIconIndex, this.centerIconIndex, this.rightIconIndex];
switch (this.updatePosition) {
case 0:
this.leftIconIndex = this.getRandomIcon(excludeIndices);
break;
case 1:
this.centerIconIndex = this.getRandomIcon(excludeIndices);
break;
case 2:
this.rightIconIndex = this.getRandomIcon(excludeIndices);
break;
}
this.updatePosition = (this.updatePosition + Math.ceil(Math.random() * 2)) % 3;
},
},
};
</script>
<style scoped>
.animated-icon {
width: 3.5rem !important;
height: 3.5rem !important;
}
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.5s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
</style>

View file

@ -253,13 +253,13 @@ export default {
day: "numeric",
}).format(date);
},
fmtSecondUnit: function (seconds) {
fmtDurationUnit: function (value, unit = "second") {
return new Intl.NumberFormat(this.$i18n?.locale, {
style: "unit",
unit: "second",
unit,
unitDisplay: "long",
})
.formatToParts(seconds)
.formatToParts(value)
.find((part) => part.type === "unit").value;
},
fmtMoney: function (amout = 0, currency = "EUR", decimals = true, withSymbol = false) {

View file

@ -0,0 +1,3 @@
export default function (obj) {
return JSON.parse(JSON.stringify(obj));
}

View file

@ -18,7 +18,6 @@
<p class="mb-1"><strong>Missing features</strong></p>
<ul>
<li>aux meters</li>
<li>loadpoints and chargers</li>
<li>custom/plugin meters and vehicles</li>
<li>migration for vehicles, chargers, meters, loadpoints</li>
<li>remove mixed mode (evcc.yaml + db) for meters and vehicles</li>
@ -36,31 +35,85 @@
<GeneralConfig @site-changed="siteChanged" />
<div v-if="$hiddenFeatures()">
<h2 class="my-4">{{ $t("config.section.loadpoints") }} 🧪</h2>
<ul class="p-0 config-list">
<DeviceCard
v-for="loadpoint in loadpoints"
:key="loadpoint.name"
:name="loadpoint.title"
:editable="!!loadpoint.id"
data-testid="loadpoint"
@edit="editLoadpoint(loadpoint.id)"
>
<template #tags>
<DeviceTags :tags="loadpointTags(loadpoint)" />
</template>
<template #icon>
<VehicleIcon
v-if="chargerIcon(loadpoint.charger)"
:name="chargerIcon(loadpoint.charger)"
/>
<LoadpointIcon v-else />
</template>
</DeviceCard>
<NewDeviceButton
data-testid="add-loadpoint"
:title="$t('config.main.addLoadpoint')"
@click="newLoadpoint"
/>
</ul>
<h2 class="my-4">{{ $t("config.section.vehicles") }} 🧪</h2>
<ul class="p-0 config-list">
<DeviceCard
v-for="vehicle in vehicles"
:key="vehicle.name"
:name="vehicle.config?.title || vehicle.name"
:editable="vehicle.id >= 0"
:error="deviceError('vehicle', vehicle.name)"
data-testid="vehicle"
@edit="editVehicle(vehicle.id)"
>
<template #icon>
<VehicleIcon :name="vehicle.config?.icon" />
</template>
<template #tags>
<DeviceTags :tags="deviceTags('vehicle', vehicle.name)" />
</template>
</DeviceCard>
<NewDeviceButton
data-testid="add-vehicle"
:title="$t('config.main.addVehicle')"
@click="newVehicle"
/>
</ul>
<h2 class="my-4 mt-5">{{ $t("config.section.grid") }} 🧪</h2>
<ul class="p-0 config-list">
<DeviceCard
v-if="gridMeter?.id"
:name="$t('config.grid.title')"
:editable="!gridMeter || !!gridMeter.id"
:error="deviceError('meter', gridMeter?.name)"
editable
:error="deviceError('meter', gridMeter.name)"
data-testid="grid"
@edit="
gridMeter?.id ? editMeter(gridMeter.id, 'grid') : addMeter('grid')
"
@edit="editMeter(gridMeter.id, 'grid')"
>
<template #icon>
<shopicon-regular-powersupply></shopicon-regular-powersupply>
</template>
<template #tags>
<DeviceTags
:tags="
gridMeter
? deviceTags('meter', gridMeter.name)
: { configured: { value: false } }
"
/>
<DeviceTags :tags="deviceTags('meter', gridMeter.name)" />
</template>
</DeviceCard>
<NewDeviceButton
v-else
:title="$t('config.main.addGrid')"
data-testid="add-grid"
@click="newMeter('grid')"
/>
<DeviceCard
v-if="tariffTags"
:name="$t('config.tariffs.title')"
editable
:error="fatalClass === 'tariff'"
@ -74,6 +127,12 @@
<DeviceTags :tags="tariffTags" />
</template>
</DeviceCard>
<NewDeviceButton
v-else
:title="$t('config.main.addTariffs')"
data-testid="add-tariffs"
@click="openModal('tariffsModal')"
/>
</ul>
<h2 class="my-4 mt-5">{{ $t("config.section.meter") }} 🧪</h2>
<ul class="p-0 config-list">
@ -109,56 +168,9 @@
<DeviceTags :tags="deviceTags('meter', meter.name)" />
</template>
</DeviceCard>
<AddDeviceButton :title="$t('config.main.addPvBattery')" @add="addMeter" />
</ul>
<h2 class="my-4 wip">{{ $t("config.section.loadpoints") }} 🧪</h2>
<ul class="p-0 config-list wip">
<DeviceCard
name="Fake Carport"
editable
:error="deviceError('charger', 'fake-charger')"
data-testid="chargepoint-1"
@edit="todo"
>
<template #icon>
<shopicon-regular-cablecharge></shopicon-regular-cablecharge>
</template>
<template #tags>
<DeviceTags :tags="{ power: 0 }" />
</template>
</DeviceCard>
<AddDeviceButton
data-testid="add-loadpoint"
:title="$t('config.main.addLoadpoint')"
@click="todo"
/>
</ul>
<h2 class="my-4">{{ $t("config.section.vehicles") }} 🧪</h2>
<div>
<ul class="p-0 config-list">
<DeviceCard
v-for="vehicle in vehicles"
:key="vehicle.name"
:name="vehicle.config?.title || vehicle.name"
:editable="vehicle.id >= 0"
:error="deviceError('vehicle', vehicle.name)"
data-testid="vehicle"
@edit="editVehicle(vehicle.id)"
>
<template #icon>
<VehicleIcon :name="vehicle.config?.icon" />
</template>
<template #tags>
<DeviceTags :tags="deviceTags('vehicle', vehicle.name)" />
</template>
</DeviceCard>
<AddDeviceButton
data-testid="add-vehicle"
:title="$t('config.main.addVehicle')"
@click="addVehicle"
<NewDeviceButton
:title="$t('config.main.addPvBattery')"
@click="newMeter"
/>
</ul>
@ -213,6 +225,7 @@
<DeviceTags :tags="eebusTags" />
</template>
</DeviceCard>
<DeviceCard
:name="`${$t('config.circuits.title')} 🧪`"
editable
@ -249,7 +262,7 @@
>
<template #icon><ModbusProxyIcon /></template>
<template #tags>
<DeviceTags :tags="modbusproxyTags" />
<DeviceTags :tags="yamlTags('modbusproxy')" />
</template>
</DeviceCard>
<DeviceCard
@ -266,7 +279,6 @@
</DeviceCard>
</ul>
</div>
</div>
<hr class="my-5" />
@ -280,14 +292,41 @@
</button>
</div>
<LoadpointModal
:id="selectedLoadpointId"
ref="loadpointModal"
:vehicleOptions="vehicleOptions"
:loadpointCount="loadpoints.length"
:chargers="chargers"
:chargerValues="deviceValues.charger"
:meters="meters"
:circuits="circuits"
:fade="loadpointSubModalOpen ? 'left' : ''"
@updated="loadpointChanged"
@open-charger-modal="editLoadpointCharger"
@open-meter-modal="editLoadpointMeter"
@opened="loadpointSubModalOpen = false"
/>
<VehicleModal :id="selectedVehicleId" @vehicle-changed="vehicleChanged" />
<MeterModal
:id="selectedMeterId"
:name="selectedMeterName"
:type="selectedMeterType"
@added="addMeterToSite"
:fade="loadpointSubModalOpen ? 'right' : ''"
@added="addMeter"
@updated="meterChanged"
@removed="removeMeterFromSite"
@removed="removeMeter"
@close="meterModalClosed"
/>
<ChargerModal
:id="selectedChargerId"
:name="selectedChargerName"
:fade="loadpointSubModalOpen ? 'right' : ''"
:isSponsor="isSponsor"
@added="addCharger"
@updated="chargerChanged"
@removed="removeCharger"
@close="chargerModalClosed"
/>
<InfluxModal @changed="loadDirty" />
<MqttModal @changed="loadDirty" />
@ -306,18 +345,17 @@
</template>
<script>
import TopHeader from "../components/TopHeader.vue";
import "@h2d2/shopicons/es/regular/sun";
import "@h2d2/shopicons/es/regular/batterythreequarters";
import "@h2d2/shopicons/es/regular/powersupply";
import "@h2d2/shopicons/es/regular/receivepayment";
import "@h2d2/shopicons/es/regular/cablecharge";
import AddDeviceButton from "../components/Config/AddDeviceButton.vue";
import NewDeviceButton from "../components/Config/NewDeviceButton.vue";
import api from "../api";
import ChargerModal from "../components/Config/ChargerModal.vue";
import CircuitsIcon from "../components/MaterialIcon/Circuits.vue";
import CircuitsModal from "../components/Config/CircuitsModal.vue";
import ControlModal from "../components/Config/ControlModal.vue";
import collector from "../mixins/collector";
import ControlModal from "../components/Config/ControlModal.vue";
import DeviceCard from "../components/Config/DeviceCard.vue";
import DeviceTags from "../components/Config/DeviceTags.vue";
import EebusIcon from "../components/MaterialIcon/Eebus.vue";
@ -328,6 +366,8 @@ import HemsIcon from "../components/MaterialIcon/Hems.vue";
import HemsModal from "../components/Config/HemsModal.vue";
import InfluxIcon from "../components/MaterialIcon/Influx.vue";
import InfluxModal from "../components/Config/InfluxModal.vue";
import LoadpointModal from "../components/Config/LoadpointModal.vue";
import LoadpointIcon from "../components/MaterialIcon/Loadpoint.vue";
import MessagingModal from "../components/Config/MessagingModal.vue";
import MeterModal from "../components/Config/MeterModal.vue";
import Modal from "bootstrap/js/dist/modal";
@ -338,16 +378,18 @@ import MqttModal from "../components/Config/MqttModal.vue";
import NetworkModal from "../components/Config/NetworkModal.vue";
import NotificationIcon from "../components/MaterialIcon/Notification.vue";
import restart, { performRestart } from "../restart";
import store from "../store";
import SponsorModal from "../components/Config/SponsorModal.vue";
import store from "../store";
import TariffsModal from "../components/Config/TariffsModal.vue";
import TopHeader from "../components/TopHeader.vue";
import VehicleIcon from "../components/VehicleIcon";
import VehicleModal from "../components/Config/VehicleModal.vue";
export default {
name: "Config",
components: {
AddDeviceButton,
NewDeviceButton,
ChargerModal,
CircuitsIcon,
CircuitsModal,
ControlModal,
@ -362,6 +404,8 @@ export default {
InfluxModal,
MessagingModal,
MeterModal,
LoadpointModal,
LoadpointIcon,
ModbusProxyIcon,
ModbusProxyModal,
MqttIcon,
@ -383,11 +427,16 @@ export default {
return {
vehicles: [],
meters: [],
loadpoints: [],
chargers: [],
circuits: [],
selectedVehicleId: undefined,
selectedMeterId: undefined,
selectedMeterType: undefined,
site: { grid: "", pv: [], battery: [] },
selectedChargerId: undefined,
selectedLoadpointId: undefined,
loadpointSubModalOpen: false,
site: { grid: "", pv: [], battery: [], title: "" },
deviceValueTimeout: undefined,
deviceValues: {},
};
@ -414,8 +463,14 @@ export default {
selectedMeterName() {
return this.getMeterById(this.selectedMeterId)?.name;
},
selectedChargerName() {
return this.getChargerById(this.selectedChargerId)?.name;
},
tariffTags() {
const { currency, tariffGrid, tariffFeedIn, tariffCo2 } = store.state;
if (tariffGrid === undefined && tariffFeedIn === undefined && tariffCo2 === undefined) {
return null;
}
const tags = {};
if (currency) {
tags.currency = { value: currency };
@ -447,6 +502,9 @@ export default {
if (org) result.org = { value: org };
return result;
},
vehicleOptions() {
return this.vehicles.map((v) => ({ key: v.name, name: v.config?.title || v.name }));
},
hemsTags() {
const result = { configured: { value: false } };
const { type } = store.state?.hems || {};
@ -456,6 +514,10 @@ export default {
}
return result;
},
isSponsor() {
const { name } = store.state?.sponsor || {};
return !!name;
},
eebusTags() {
return { configured: { value: store.state?.eebus || false } };
},
@ -488,6 +550,8 @@ export default {
await this.loadVehicles();
await this.loadMeters();
await this.loadSite();
await this.loadChargers();
await this.loadLoadpoints();
await this.loadCircuits();
await this.loadDirty();
await this.updateValues();
@ -502,6 +566,10 @@ export default {
const response = await api.get("/config/devices/vehicle");
this.vehicles = response.data?.result || [];
},
async loadChargers() {
const response = await api.get("/config/devices/charger");
this.chargers = response.data?.result || [];
},
async loadMeters() {
const response = await api.get("/config/devices/meter");
this.meters = response.data?.result || [];
@ -518,6 +586,10 @@ export default {
this.site = response.data?.result;
}
},
async loadLoadpoints() {
const response = await api.get("/config/loadpoints");
this.loadpoints = response.data?.result || [];
},
getMetersByNames(names) {
if (!names || !this.meters) {
return [];
@ -530,35 +602,95 @@ export default {
}
return this.meters.find((m) => m.id === id);
},
getChargerById(id) {
if (!id || !this.chargers) {
return undefined;
}
return this.chargers.find((c) => c.id === id);
},
vehicleModal() {
return Modal.getOrCreateInstance(document.getElementById("vehicleModal"));
},
meterModal() {
return Modal.getOrCreateInstance(document.getElementById("meterModal"));
},
loadpointModal() {
return Modal.getOrCreateInstance(document.getElementById("loadpointModal"));
},
chargerModal() {
return Modal.getOrCreateInstance(document.getElementById("chargerModal"));
},
editLoadpointCharger(name) {
this.loadpointSubModalOpen = true;
const charger = this.chargers.find((c) => c.name === name);
if (charger && charger.id === undefined) {
alert(
"yaml configured chargers can not be edited. Remove charger from yaml first."
);
return;
}
this.loadpointModal().hide();
this.$nextTick(() => this.editCharger(charger?.id));
},
editLoadpointMeter(name) {
this.loadpointSubModalOpen = true;
const meter = this.meters.find((m) => m.name === name);
if (meter && meter.id === undefined) {
alert("yaml configured meters can not be edited. Remove meter from yaml first.");
return;
}
this.loadpointModal().hide();
this.$nextTick(() => this.editMeter(meter?.id, "charge"));
},
editMeter(id, type) {
this.selectedMeterId = id;
this.selectedMeterType = type;
this.$nextTick(() => this.meterModal().show());
},
addMeter(type) {
newMeter(type) {
this.selectedMeterId = undefined;
this.selectedMeterType = type;
this.$nextTick(() => this.meterModal().show());
},
editCharger(id) {
this.selectedChargerId = id;
this.$nextTick(() => this.chargerModal().show());
},
newCharger() {
this.selectedChargerId = undefined;
this.$nextTick(() => this.chargerModal().show());
},
async meterChanged() {
this.selectedMeterId = undefined;
this.selectedMeterType = undefined;
await this.loadMeters();
this.meterModal().hide();
await this.loadDirty();
await this.updateValues();
},
async chargerChanged() {
await this.loadChargers();
this.chargerModal().hide();
await this.loadDirty();
await this.updateValues();
},
editLoadpoint(id) {
this.selectedLoadpointId = id;
this.$nextTick(() => this.loadpointModal().show());
},
newLoadpoint() {
this.selectedLoadpointId = undefined;
this.$nextTick(() => this.loadpointModal().show());
},
async loadpointChanged() {
this.selectedLoadpointId = undefined;
await this.loadLoadpoints();
this.loadpointModal().hide();
this.loadDirty();
},
editVehicle(id) {
this.selectedVehicleId = id;
this.$nextTick(() => this.vehicleModal().show());
},
addVehicle() {
newVehicle() {
this.selectedVehicleId = undefined;
this.$nextTick(() => this.vehicleModal().show());
},
@ -574,24 +706,52 @@ export default {
yamlChanged() {
this.loadDirty();
},
addMeterToSite(type, name) {
if (type === "grid") {
addMeter(type, name) {
if (type === "charge") {
// update loadpoint
this.$refs.loadpointModal?.setMeter(name);
} else if (type === "grid") {
// update site grid
this.site.grid = name;
this.saveSite(type);
} else {
// update site pv, battery, aux
if (!this.site[type]) {
this.site[type] = [];
}
this.site[type].push(name);
}
this.saveSite(type);
}
},
removeMeterFromSite(type, name) {
if (type === "grid") {
removeMeter(type, name) {
if (type === "charge") {
// update loadpoint
this.$refs.loadpointModal?.setMeter(undefined);
} else if (type === "grid") {
// update site grid
this.site.grid = "";
} else if (this.site[type]) {
this.site[type] = this.site[type].filter((i) => i !== name);
}
this.saveSite(type);
} else {
// update site pv, battery, aux
this.site[type] = this.site[type].filter((i) => i !== name);
this.saveSite(type);
}
},
addCharger(name) {
this.$refs.loadpointModal?.setCharger(name);
},
removeCharger() {
this.$refs.loadpointModal?.setCharger(undefined);
},
meterModalClosed() {
if (this.selectedMeterType === "charge") {
// reopen loadpoint modal
this.loadpointModal().show();
}
},
chargerModalClosed() {
// reopen loadpoint modal
this.loadpointModal().show();
},
async saveSite(key) {
const body = key ? { [key]: this.site[key] } : this.site;
@ -619,11 +779,15 @@ export default {
async updateValues() {
clearTimeout(this.deviceValueTimeout);
if (!this.offline) {
for (const meter of this.meters) {
await this.updateDeviceValue("meter", meter.name);
const devices = {
meter: this.meters,
vehicle: this.vehicles,
charger: this.chargers,
};
for (const type in devices) {
for (const device of devices[type]) {
await this.updateDeviceValue(type, device.name);
}
for (const vehicle of this.vehicles) {
await this.updateDeviceValue("vehicle", vehicle.name);
}
}
// ensure that component is still mounted
@ -632,7 +796,13 @@ export default {
this.deviceValueTimeout = setTimeout(this.updateValues, interval);
},
deviceTags(type, id) {
return this.deviceValues[type]?.[id] || [];
return this.deviceValues[type]?.[id] || {};
},
loadpointTags(loadpoint) {
const { charger, meter } = loadpoint;
const chargerTags = charger ? this.deviceTags("charger", charger) : {};
const meterTags = meter ? this.deviceTags("meter", meter) : {};
return { ...chargerTags, ...meterTags };
},
openModal(id) {
const $el = document.getElementById(id);
@ -643,7 +813,8 @@ export default {
}
},
circuitTags(circuit) {
const data = store.state?.circuits[circuit.name] || {};
const circuits = store.state?.circuits || {};
const data = circuits[circuit.name] || {};
const result = {};
if (data.maxPower) {
result.powerRange = {
@ -665,6 +836,10 @@ export default {
const fatal = store.state?.fatal || {};
return fatal.class === type && fatal.device === name;
},
chargerIcon(chargerName) {
const charger = this.chargers.find((c) => c.name === chargerName);
return charger?.config?.icon;
},
},
};
</script>

View file

@ -29,7 +29,7 @@ var acceptable = []string{
"(Client.Timeout exceeded while awaiting headers)",
"can only have either uri or device", // modbus
"connection already registered with different protocol: localhost:502", // modbus
"sponsorship required, see https://github.com/evcc-io/evcc#sponsorship",
"sponsorship required, see https://docs.evcc.io/docs/sponsorship",
"eebus not configured",
"context deadline exceeded",
"timeout", // ocpp

View file

@ -262,7 +262,6 @@ tariffs:
feedin:
type: fixed
price: 0.08 # EUR/kWh
co2:
type: template
template: grünstromindex
zip: 12349
#co2:
# type: grünstromindex
# zip: 12349

View file

@ -2,7 +2,9 @@ package cmd
import (
"github.com/evcc-io/evcc/core/keys"
"github.com/evcc-io/evcc/server/db"
"github.com/evcc-io/evcc/server/db/settings"
"github.com/evcc-io/evcc/util/config"
"github.com/spf13/cobra"
)
@ -34,83 +36,95 @@ func runMigrate(cmd *cobra.Command, args []string) {
// TODO remove yaml file
if reset {
log.WARN.Println("resetting:")
log.INFO.Println("resetting:")
} else {
log.WARN.Println("migrating:")
log.INFO.Println("migrating:")
}
log.DEBUG.Println("- global settings")
log.INFO.Println("- global settings")
if reset {
settings.Delete(keys.Interval)
settings.Delete(keys.SponsorToken)
settings.Delete(keys.Title)
} else {
settings.SetInt(keys.Interval, int64(conf.Interval))
settings.SetString(keys.SponsorToken, conf.SponsorToken)
}
log.DEBUG.Println("- network")
log.INFO.Println("- network")
if reset {
settings.Delete(keys.Network)
} else {
_ = settings.SetJson(keys.Network, conf.Network)
}
log.DEBUG.Println("- mqtt")
log.INFO.Println("- mqtt")
if reset {
settings.Delete(keys.Mqtt)
} else {
_ = settings.SetJson(keys.Mqtt, conf.Mqtt)
}
log.DEBUG.Println("- influx")
log.INFO.Println("- influx")
if reset {
settings.Delete(keys.Influx)
} else {
_ = settings.SetJson(keys.Influx, conf.Influx)
}
log.DEBUG.Println("- hems")
log.INFO.Println("- hems")
if reset {
settings.Delete(keys.Hems)
} else if conf.HEMS.Type != "" {
_ = settings.SetYaml(keys.Hems, conf.HEMS)
}
log.DEBUG.Println("- eebus")
log.INFO.Println("- eebus")
if reset {
settings.Delete(keys.EEBus)
} else if conf.EEBus.Configured() {
_ = settings.SetYaml(keys.EEBus, conf.EEBus)
}
log.DEBUG.Println("- modbusproxy")
log.INFO.Println("- modbusproxy")
if reset {
settings.Delete(keys.ModbusProxy)
} else if len(conf.ModbusProxy) > 0 {
_ = settings.SetYaml(keys.ModbusProxy, conf.ModbusProxy)
}
log.DEBUG.Println("- messaging")
log.INFO.Println("- messaging")
if reset {
settings.Delete(keys.Messaging)
} else if len(conf.Messaging.Services) > 0 {
_ = settings.SetYaml(keys.Messaging, conf.Messaging)
}
log.DEBUG.Println("- tariffs")
log.INFO.Println("- tariffs")
if reset {
settings.Delete(keys.Tariffs)
} else if conf.Tariffs.Grid.Type != "" || conf.Tariffs.FeedIn.Type != "" || conf.Tariffs.Co2.Type != "" || conf.Tariffs.Planner.Type != "" {
_ = settings.SetYaml(keys.Tariffs, conf.Tariffs)
}
log.DEBUG.Println("- circuits")
log.INFO.Println("- circuits")
if reset {
settings.Delete(keys.Circuits)
} else if len(conf.Circuits) > 0 {
_ = settings.SetYaml(keys.Circuits, conf.Circuits)
}
log.INFO.Println("- device configs")
if reset {
// site keys
settings.Delete(keys.GridMeter)
settings.Delete(keys.PvMeters)
settings.Delete(keys.AuxMeters)
settings.Delete(keys.BatteryMeters)
// clear config table
result := db.Instance.Delete(&config.Config{}, "true")
log.INFO.Printf(" %d entries deleted", result.RowsAffected)
}
// wait for shutdown
<-shutdownDoneC()
}

98
cmd/refs.go Normal file
View file

@ -0,0 +1,98 @@
package cmd
import (
"iter"
"slices"
"strings"
"github.com/evcc-io/evcc/api/globalconfig"
"github.com/evcc-io/evcc/core"
"github.com/evcc-io/evcc/core/keys"
"github.com/evcc-io/evcc/server/db/settings"
"github.com/evcc-io/evcc/util"
"github.com/evcc-io/evcc/util/config"
"github.com/evcc-io/evcc/util/templates"
)
var references struct {
meter, charger, vehicle, circuit []string
}
func collectRefs(conf globalconfig.All) error {
// site
if err := collectSiteRefs(conf); err != nil {
return err
}
// loadpoints
if err := collectLoadpointRefs(slices.Values(conf.Loadpoints)); err != nil {
return err
}
// append devices from database
configurable, err := config.ConfigurationsByClass(templates.Loadpoint)
if err != nil {
return err
}
return collectLoadpointRefs(func(yield func(config.Named) bool) {
for _, cc := range configurable {
if !yield(cc.Named()) {
return
}
}
})
}
func collectSiteRefs(conf globalconfig.All) error {
var refs struct {
Meters core.MetersConfig `mapstructure:"meters"` // Meter references
Other map[string]any `mapstructure:",remain"`
}
if err := util.DecodeOther(conf.Site, &refs); err != nil {
return err
}
references.meter = append(references.meter, refs.Meters.GridMeterRef)
references.meter = append(references.meter, refs.Meters.PVMetersRef...)
references.meter = append(references.meter, refs.Meters.BatteryMetersRef...)
references.meter = append(references.meter, refs.Meters.ExtMetersRef...)
references.meter = append(references.meter, refs.Meters.AuxMetersRef...)
// append devices from settings
if v, err := settings.String(keys.GridMeter); err == nil && v != "" {
references.meter = append(references.meter, v)
}
for _, key := range []string{keys.PvMeters, keys.BatteryMeters, keys.ExtMeters, keys.AuxMeters} {
if v, err := settings.String(key); err == nil && v != "" {
references.meter = append(references.meter, strings.Split(v, ",")...)
}
}
return nil
}
func collectLoadpointRefs(named iter.Seq[config.Named]) error {
for cc := range named {
var refs struct {
CircuitRef string `mapstructure:"circuit"` // Circuit reference
ChargerRef string `mapstructure:"charger"` // Charger reference
VehicleRef string `mapstructure:"vehicle"` // Vehicle reference
MeterRef string `mapstructure:"meter"` // Charge meter reference
Other map[string]any `mapstructure:",remain"`
}
if err := util.DecodeOther(cc.Other, &refs); err != nil {
return err
}
references.meter = append(references.meter, refs.MeterRef)
references.charger = append(references.charger, refs.ChargerRef)
references.vehicle = append(references.vehicle, refs.VehicleRef)
references.circuit = append(references.circuit, refs.CircuitRef)
}
return nil
}

View file

@ -22,6 +22,8 @@ import (
"github.com/evcc-io/evcc/core"
"github.com/evcc-io/evcc/core/circuit"
"github.com/evcc-io/evcc/core/keys"
"github.com/evcc-io/evcc/core/loadpoint"
coresettings "github.com/evcc-io/evcc/core/settings"
"github.com/evcc-io/evcc/hems"
"github.com/evcc-io/evcc/meter"
"github.com/evcc-io/evcc/plugin/golang"
@ -46,6 +48,7 @@ import (
"github.com/gorilla/handlers"
"github.com/gorilla/mux"
"github.com/libp2p/zeroconf/v2"
"github.com/samber/lo"
"github.com/spf13/cast"
"github.com/spf13/cobra"
"golang.org/x/sync/errgroup"
@ -262,7 +265,7 @@ func configureMeters(static []config.Named, names ...string) error {
return &DeviceError{cc.Name, fmt.Errorf("cannot create meter '%s': %w", cc.Name, err)}
}
if err := config.Meters().Add(config.NewConfigurableDevice(conf, instance)); err != nil {
if err := config.Meters().Add(config.NewConfigurableDevice(&conf, instance)); err != nil {
return &DeviceError{cc.Name, err}
}
@ -326,7 +329,7 @@ func configureChargers(static []config.Named, names ...string) error {
return fmt.Errorf("cannot create charger '%s': %w", cc.Name, err)
}
if err := config.Chargers().Add(config.NewConfigurableDevice(conf, instance)); err != nil {
if err := config.Chargers().Add(config.NewConfigurableDevice(&conf, instance)); err != nil {
return &DeviceError{cc.Name, err}
}
@ -419,7 +422,7 @@ func configureVehicles(static []config.Named, names ...string) error {
mu.Lock()
defer mu.Unlock()
devs2 = append(devs2, config.NewConfigurableDevice(conf, instance))
devs2 = append(devs2, config.NewConfigurableDevice(&conf, instance))
return nil
})
@ -796,11 +799,16 @@ func configureTariffs(conf globalconfig.Tariffs) (*tariff.Tariffs, error) {
}
func configureDevices(conf globalconfig.All) error {
// collect references for filtering used devices
if err := collectRefs(conf); err != nil {
return err
}
// TODO: add name/identifier to error for better highlighting in UI
if err := configureMeters(conf.Meters); err != nil {
if err := configureMeters(conf.Meters, references.meter...); err != nil {
return &ClassError{ClassMeter, err}
}
if err := configureChargers(conf.Chargers); err != nil {
if err := configureChargers(conf.Chargers, references.charger...); err != nil {
return &ClassError{ClassCharger, err}
}
if err := configureVehicles(conf.Vehicles); err != nil {
@ -853,8 +861,7 @@ func configureSiteAndLoadpoints(conf *globalconfig.All) (*core.Site, error) {
return nil, err
}
loadpoints, err := configureLoadpoints(*conf)
if err != nil {
if err := configureLoadpoints(*conf); err != nil {
return nil, fmt.Errorf("failed configuring loadpoints: %w", err)
}
@ -863,6 +870,11 @@ func configureSiteAndLoadpoints(conf *globalconfig.All) (*core.Site, error) {
return nil, &ClassError{ClassTariff, err}
}
loadpoints := lo.Map(config.Loadpoints().Devices(), func(dev config.Device[loadpoint.API], _ int) *core.Loadpoint {
lp := dev.Instance()
return lp.(*core.Loadpoint)
})
site, err := configureSite(conf.Site, loadpoints, tariffs)
if err != nil {
return nil, err
@ -924,26 +936,59 @@ func configureSite(conf map[string]interface{}, loadpoints []*core.Loadpoint, ta
return site, nil
}
func configureLoadpoints(conf globalconfig.All) ([]*core.Loadpoint, error) {
if len(conf.Loadpoints) == 0 {
return nil, errors.New("missing loadpoints")
}
func configureLoadpoints(conf globalconfig.All) error {
for id, cc := range conf.Loadpoints {
cc.Name = "lp-" + strconv.Itoa(id+1)
var loadpoints []*core.Loadpoint
log := util.NewLoggerWithLoadpoint(cc.Name, id+1)
settings := coresettings.NewDatabaseSettingsAdapter(fmt.Sprintf("lp%d.", id+1))
for id, cfg := range conf.Loadpoints {
log := util.NewLoggerWithLoadpoint("lp-"+strconv.Itoa(id+1), id+1)
settings := &core.Settings{Key: "lp" + strconv.Itoa(id+1) + "."}
lp, err := core.NewLoadpointFromConfig(log, settings, cfg)
instance, err := core.NewLoadpointFromConfig(log, settings, cc.Other)
if err != nil {
return nil, fmt.Errorf("failed configuring loadpoint: %w", err)
return fmt.Errorf("failed configuring loadpoint: %w", err)
}
loadpoints = append(loadpoints, lp)
if err := config.Loadpoints().Add(config.NewStaticDevice(cc, loadpoint.API(instance))); err != nil {
return &DeviceError{cc.Name, err}
}
}
return loadpoints, nil
// append devices from database
configurable, err := config.ConfigurationsByClass(templates.Loadpoint)
if err != nil {
return err
}
for _, conf := range configurable {
cc := conf.Named()
id := len(config.Loadpoints().Devices())
name := "lp-" + strconv.Itoa(id+1)
log := util.NewLoggerWithLoadpoint(name, id+1)
settings := coresettings.NewConfigSettingsAdapter(log, &conf)
dynamic, static, err := loadpoint.SplitConfig(cc.Other)
if err != nil {
return fmt.Errorf("failed configuring loadpoint: %w", err)
}
instance, err := core.NewLoadpointFromConfig(log, settings, static)
if err != nil {
return fmt.Errorf("failed configuring loadpoint: %w", err)
}
if err := dynamic.Apply(instance); err != nil {
return err
}
dev := config.NewConfigurableDevice[loadpoint.API](&conf, instance)
if err := config.Loadpoints().Add(dev); err != nil {
return err
}
}
return nil
}
// configureAuth handles routing for devices. For now only api.AuthProvider related routes

View file

@ -6,6 +6,7 @@ import (
"github.com/evcc-io/evcc/api"
"github.com/evcc-io/evcc/api/globalconfig"
"github.com/evcc-io/evcc/core"
"github.com/evcc-io/evcc/server/db"
"github.com/evcc-io/evcc/util/config"
"github.com/stretchr/testify/suite"
@ -59,9 +60,11 @@ loadpoints:
Name: "test",
}, api.Charger(nil))))
lps, err := configureLoadpoints(conf)
err := configureLoadpoints(conf)
suite.Require().NoError(err)
suite.Require().NotNil(lps[0].GetCircuit())
lps := config.Loadpoints().Devices()
suite.Require().NotNil(lps[0].Instance().GetCircuit())
}
func (suite *circuitsTestSuite) TestCircuitMissingLoadpoint() {
@ -88,9 +91,15 @@ loadpoints:
Name: "test",
}, api.Charger(nil))))
lps, err := configureLoadpoints(conf)
err := configureLoadpoints(conf)
suite.Require().NoError(err)
lpsd := config.Loadpoints().Devices()
lps := make([]*core.Loadpoint, 0, len(lpsd))
for _, lp := range lpsd {
lps = append(lps, lp.Instance().(*core.Loadpoint))
}
// circuit without device
err = validateCircuits(lps)
suite.Require().Error(err)
@ -140,9 +149,15 @@ loadpoints:
Name: "test",
}, api.Charger(nil))))
lps, err := configureLoadpoints(conf)
err := configureLoadpoints(conf)
suite.Require().NoError(err)
lpsd := config.Loadpoints().Devices()
lps := make([]*core.Loadpoint, 0, len(lpsd))
for _, lp := range lpsd {
lps = append(lps, lp.Instance().(*core.Loadpoint))
}
// lp using root circuit is valid
suite.Require().NoError(validateCircuits(lps))
}

View file

@ -24,11 +24,11 @@ func TestYamlOff(t *testing.T) {
}
var lp core.Loadpoint
if err := util.DecodeOther(conf.Loadpoints[0], &lp); err != nil {
if err := util.DecodeOther(conf.Loadpoints[0].Other, &lp); err != nil {
t.Error(err)
}
if lp.Mode_ != api.ModeOff {
t.Errorf("expected `off`, got %s", lp.Mode_)
if lp.DefaultMode != api.ModeOff {
t.Errorf("expected `off`, got %s", lp.DefaultMode)
}
}

View file

@ -4,26 +4,30 @@ const (
// loadpoint settings
Title = "title" // loadpoint title
Mode = "mode" // charge mode
DefaultMode = "defaultMode" // default charge mode
Priority = "priority" // priority
MinCurrent = "minCurrent" // min current
MaxCurrent = "maxCurrent" // max current
MinSoc = "minSoc" // min soc
LimitSoc = "limitSoc" // limit soc
LimitEnergy = "limitEnergy" // limit energy
Soc = "soc"
Thresholds = "thresholds"
EnableThreshold = "enableThreshold"
DisableThreshold = "disableThreshold"
EnableDelay = "enableDelay"
DisableDelay = "disableDelay"
BatteryBoost = "batteryBoost"
PhasesConfigured = "phasesConfigured" // configured phases (1/3, 0 for auto on 1p3p chargers, nil for plain chargers)
Phases = "phases" // configured phases (1/3, 0 for auto on 1p3p chargers, nil for plain chargers)
PhasesConfigured = "phasesConfigured" // TODO mirrors "phases" for UI purposes
PhasesEnabled = "phasesEnabled" // enabled phases (1/3)
PhasesActive = "phasesActive" // active phases as used by vehicle (1/2/3)
ChargerIcon = "chargerIcon" // charger icon for ui
ChargerFeature = "chargerFeature" // charger feature
ChargerPhysicalPhases = "chargerPhysicalPhases" // charger phases
ChargerPhases1p3p = "chargerPhases1p3p" // phase switcher (1p3p chargers)
ChargerSinglePhase = "chargerSinglePhase" // api.PhaseDescriber: charger physical phases, sockets only
ChargerPhases1p3p = "chargerPhases1p3p" // api.PhaseSwitcher: 1p3p chargers
ChargerStatusReason = "chargerStatusReason" // either awaiting authorization or disconnect required
// loadpoint status

View file

@ -6,7 +6,6 @@ import (
"math"
"reflect"
"slices"
"strings"
"sync"
"testing"
"time"
@ -20,6 +19,7 @@ import (
"github.com/evcc-io/evcc/core/loadpoint"
"github.com/evcc-io/evcc/core/planner"
"github.com/evcc-io/evcc/core/session"
"github.com/evcc-io/evcc/core/settings"
"github.com/evcc-io/evcc/core/soc"
"github.com/evcc-io/evcc/core/vehicle"
"github.com/evcc-io/evcc/core/wrapper"
@ -64,32 +64,8 @@ const (
// elapsed is the time an expired timer will be set to
var elapsed = time.Unix(0, 1)
// PollConfig defines the vehicle polling mode and interval
type PollConfig struct {
Mode string `mapstructure:"mode"` // polling mode charging (default), connected, always
Interval time.Duration `mapstructure:"interval"` // interval when not charging
}
// SocConfig defines soc settings, estimation and update behavior
type SocConfig struct {
Poll PollConfig `mapstructure:"poll"`
Estimate *bool `mapstructure:"estimate"`
}
// Poll modes
const (
pollCharging = "charging"
pollConnected = "connected"
pollAlways = "always"
pollInterval = 60 * time.Minute
)
// ThresholdConfig defines enable/disable hysteresis parameters
type ThresholdConfig struct {
Delay time.Duration
Threshold float64
}
const pollInterval = 60 * time.Minute
// Task is the task type
type Task = func()
@ -108,23 +84,26 @@ type Loadpoint struct {
sync.RWMutex // guard status
vmu sync.RWMutex // guard vehicle
Mode_ api.ChargeMode `mapstructure:"mode"` // Default charge mode, used for disconnect
Title_ string `mapstructure:"title"` // UI title
Priority_ int `mapstructure:"priority"` // Priority
CircuitRef string `mapstructure:"circuit"` // Circuit reference
ChargerRef string `mapstructure:"charger"` // Charger reference
VehicleRef string `mapstructure:"vehicle"` // Vehicle reference
MeterRef string `mapstructure:"meter"` // Charge meter reference
Soc SocConfig
Enable, Disable ThresholdConfig
Soc loadpoint.SocConfig
Enable, Disable loadpoint.ThresholdConfig
// TODO deprecated
DefaultMode api.ChargeMode `mapstructure:"mode"` // Default charge mode, used for disconnect
Title_ string `mapstructure:"title"` // UI title
Priority_ int `mapstructure:"priority"` // Priority
GuardDuration_ time.Duration `mapstructure:"guardduration"` // charger enable/disable minimum holding time
ConfiguredPhases_ int `mapstructure:"phases"`
Phases_ int `mapstructure:"phases"`
MinCurrent_ float64 `mapstructure:"minCurrent"`
MaxCurrent_ float64 `mapstructure:"maxCurrent"`
title string // UI title
priority int // Priority
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
@ -186,34 +165,31 @@ type Loadpoint struct {
db *session.DB
session *session.Session
settings *Settings
settings settings.Settings
tasks *util.Queue[Task] // tasks to be executed
}
// NewLoadpointFromConfig creates a new loadpoint
func NewLoadpointFromConfig(log *util.Logger, settings *Settings, other map[string]interface{}) (*Loadpoint, error) {
func NewLoadpointFromConfig(log *util.Logger, settings settings.Settings, other map[string]interface{}) (*Loadpoint, error) {
lp := NewLoadpoint(log, settings)
if err := util.DecodeOther(other, lp); err != nil {
return nil, err
}
// set vehicle polling mode
switch lp.Soc.Poll.Mode = strings.ToLower(lp.Soc.Poll.Mode); lp.Soc.Poll.Mode {
case pollCharging:
case pollConnected, pollAlways:
switch lp.Soc.Poll.Mode {
case loadpoint.PollCharging:
case loadpoint.PollConnected, loadpoint.PollAlways:
lp.log.WARN.Printf("poll mode '%s' may deplete your battery or lead to API misuse. USE AT YOUR OWN RISK.", lp.Soc.Poll)
default:
if lp.Soc.Poll.Mode != "" {
lp.log.WARN.Printf("invalid poll mode: %s", lp.Soc.Poll.Mode)
}
lp.Soc.Poll.Mode = pollCharging
lp.Soc.Poll.Mode = loadpoint.PollCharging
}
if lp.CircuitRef != "" {
dev, err := config.Circuits().ByName(lp.CircuitRef)
if err != nil {
return nil, err
return nil, fmt.Errorf("circuit: %w", err)
}
lp.circuit = dev.Instance()
}
@ -221,7 +197,7 @@ func NewLoadpointFromConfig(log *util.Logger, settings *Settings, other map[stri
if lp.MeterRef != "" {
dev, err := config.Meters().ByName(lp.MeterRef)
if err != nil {
return nil, err
return nil, fmt.Errorf("meter: %w", err)
}
lp.chargeMeter = dev.Instance()
}
@ -230,7 +206,7 @@ func NewLoadpointFromConfig(log *util.Logger, settings *Settings, other map[stri
if lp.VehicleRef != "" {
dev, err := config.Vehicles().ByName(lp.VehicleRef)
if err != nil {
return nil, err
return nil, fmt.Errorf("vehicle: %w", err)
}
lp.defaultVehicle = dev.Instance()
}
@ -240,7 +216,7 @@ func NewLoadpointFromConfig(log *util.Logger, settings *Settings, other map[stri
}
dev, err := config.Chargers().ByName(lp.ChargerRef)
if err != nil {
return nil, err
return nil, fmt.Errorf("charger: %w", err)
}
lp.charger = dev.Instance()
lp.configureChargerType(lp.charger)
@ -252,24 +228,7 @@ func NewLoadpointFromConfig(log *util.Logger, settings *Settings, other map[stri
}
// TODO deprecated
if lp.MinCurrent_ > 0 {
lp.log.WARN.Println("deprecated: mincurrent setting is ignored, please remove")
if _, err := lp.settings.Float(keys.MinCurrent); err != nil {
lp.settings.SetFloat(keys.MinCurrent, lp.MinCurrent_)
}
}
if lp.MaxCurrent_ > 0 {
lp.log.WARN.Println("deprecated: maxcurrent setting is ignored, please remove")
if _, err := lp.settings.Float(keys.MaxCurrent); err != nil {
lp.settings.SetFloat(keys.MaxCurrent, lp.MaxCurrent_)
}
}
if lp.ConfiguredPhases_ > 0 {
lp.log.WARN.Println("deprecated: phases setting is ignored, please remove")
if _, err := lp.settings.Int(keys.PhasesConfigured); err != nil {
lp.settings.SetInt(keys.PhasesConfigured, int64(lp.ConfiguredPhases_))
}
}
lp.migrateSettings()
// validate thresholds
if lp.Enable.Threshold > lp.Disable.Threshold {
@ -279,7 +238,7 @@ func NewLoadpointFromConfig(log *util.Logger, settings *Settings, other map[stri
}
// choose sane default if mode is not set
if lp.mode = lp.Mode_; lp.mode == "" {
if lp.mode = lp.DefaultMode; lp.mode == "" {
lp.mode = api.ModeOff
}
@ -287,7 +246,7 @@ func NewLoadpointFromConfig(log *util.Logger, settings *Settings, other map[stri
}
// NewLoadpoint creates a Loadpoint with sane defaults
func NewLoadpoint(log *util.Logger, settings *Settings) *Loadpoint {
func NewLoadpoint(log *util.Logger, settings settings.Settings) *Loadpoint {
clock := clock.New()
bus := evbus.New()
@ -300,14 +259,14 @@ func NewLoadpoint(log *util.Logger, settings *Settings) *Loadpoint {
status: api.StatusNone,
minCurrent: 6, // A
maxCurrent: 16, // A
Soc: SocConfig{
Poll: PollConfig{
Soc: loadpoint.SocConfig{
Poll: loadpoint.PollConfig{
Interval: pollInterval,
Mode: pollCharging,
Mode: loadpoint.PollCharging,
},
},
Enable: ThresholdConfig{Delay: time.Minute, Threshold: 0}, // t, W
Disable: ThresholdConfig{Delay: 3 * time.Minute, Threshold: 0}, // t, W
Enable: loadpoint.ThresholdConfig{Delay: time.Minute, Threshold: 0}, // t, W
Disable: loadpoint.ThresholdConfig{Delay: 3 * time.Minute, Threshold: 0}, // t, W
progress: NewProgress(0, 10), // soc progress indicator
coordinator: coordinator.NewDummy(), // dummy vehicle coordinator
tasks: util.NewQueue[Task](), // task queue
@ -316,15 +275,68 @@ func NewLoadpoint(log *util.Logger, settings *Settings) *Loadpoint {
return lp
}
// migrateSettings migrates loadpoint settings
func (lp *Loadpoint) migrateSettings() {
// One-time migrations MUST be mirrored in restoreSettings
if lp.DefaultMode != "" {
lp.log.WARN.Println("deprecated: mode setting is ignored, please remove")
if _, err := lp.settings.String(keys.Mode); err != nil {
lp.settings.SetString(keys.Mode, string(lp.DefaultMode))
}
}
if lp.Title_ != "" {
lp.log.WARN.Println("deprecated: title setting is ignored, please remove")
if _, err := lp.settings.String(keys.Title); err != nil {
lp.settings.SetString(keys.Title, lp.Title_)
}
}
if lp.Priority_ > 0 {
lp.log.WARN.Println("deprecated: priority setting is ignored, please remove")
if _, err := lp.settings.String(keys.Priority); err != nil {
lp.settings.SetInt(keys.Priority, int64(lp.Priority_))
}
}
if lp.MinCurrent_ > 0 {
lp.log.WARN.Println("deprecated: mincurrent setting is ignored, please remove")
if _, err := lp.settings.Float(keys.MinCurrent); err != nil {
lp.settings.SetFloat(keys.MinCurrent, lp.MinCurrent_)
}
}
if lp.MaxCurrent_ > 0 {
lp.log.WARN.Println("deprecated: maxcurrent setting is ignored, please remove")
if _, err := lp.settings.Float(keys.MaxCurrent); err != nil {
lp.settings.SetFloat(keys.MaxCurrent, lp.MaxCurrent_)
}
}
if lp.Phases_ > 0 {
lp.log.WARN.Println("deprecated: phases setting is ignored, please remove")
if _, err := lp.settings.Int(keys.Phases); err != nil {
lp.settings.SetInt(keys.Phases, int64(lp.Phases_))
}
}
if lp.Soc.Estimate != nil || lp.Soc.Poll.Mode != loadpoint.PollCharging || lp.Soc.Poll.Interval != 0 {
lp.log.WARN.Println("deprecated: soc setting is ignored, please remove")
if _, err := lp.settings.String(keys.Soc); err != nil {
lp.settings.SetJson(keys.Soc, lp.Soc)
}
}
}
// restoreSettings restores loadpoint settings
func (lp *Loadpoint) restoreSettings() {
if testing.Testing() {
return
}
if v, err := lp.settings.String(keys.Title); err == nil && v != "" {
lp.setTitle(v)
}
if v, err := lp.settings.String(keys.Mode); err == nil && v != "" {
lp.setMode(api.ChargeMode(v))
}
if v, err := lp.settings.Int(keys.PhasesConfigured); err == nil && (v > 0 || lp.hasPhaseSwitching()) {
if v, err := lp.settings.Int(keys.Priority); err == nil && v > 0 {
lp.setPriority(int(v))
}
if v, err := lp.settings.Int(keys.Phases); err == nil && (v > 0 || lp.hasPhaseSwitching()) {
lp.setConfiguredPhases(int(v))
lp.phases = lp.configuredPhases
}
@ -344,6 +356,16 @@ func (lp *Loadpoint) restoreSettings() {
lp.SetSmartCostLimit(&v)
}
var thresholds loadpoint.ThresholdsConfig
if err := lp.settings.Json(keys.Thresholds, &thresholds); err == nil {
lp.setThresholds(thresholds)
}
var socConfig loadpoint.SocConfig
if err := lp.settings.Json(keys.Soc, &socConfig); err == nil {
lp.setSocConfig(socConfig)
}
t, err1 := lp.settings.Time(keys.PlanTime)
v, err2 := lp.settings.Float(keys.PlanEnergy)
if err1 == nil && err2 == nil {
@ -584,7 +606,7 @@ func (lp *Loadpoint) evChargeCurrentWrappedMeterHandler(current float64) {
// defaultMode executes the action
func (lp *Loadpoint) defaultMode() {
lp.RLock()
mode := lp.Mode_
mode := lp.DefaultMode
lp.RUnlock()
if mode != "" && mode != lp.GetMode() {
@ -610,7 +632,7 @@ func (lp *Loadpoint) Prepare(uiChan chan<- util.Param, pushChan chan<- push.Even
lp.restoreSettings()
// publish initial values
lp.publish(keys.Title, lp.Title())
lp.publish(keys.Title, lp.GetTitle())
lp.publish(keys.Mode, lp.GetMode())
lp.publish(keys.Priority, lp.GetPriority())
lp.publish(keys.MinCurrent, lp.GetMinCurrent())
@ -627,15 +649,17 @@ func (lp *Loadpoint) Prepare(uiChan chan<- util.Param, pushChan chan<- push.Even
}
lp.phases = phases
lp.configuredPhases = phases
lp.publish(keys.ChargerPhysicalPhases, phases)
lp.publish(keys.Phases, phases)
} else {
lp.publish(keys.ChargerPhysicalPhases, nil)
lp.publish(keys.Phases, nil)
}
lp.publish(keys.PhasesConfigured, lp.configuredPhases)
lp.publish(keys.ChargerPhases1p3p, lp.hasPhaseSwitching())
lp.publish(keys.ChargerSinglePhase, lp.getChargerPhysicalPhases() == 1)
lp.publish(keys.PhasesEnabled, lp.phases)
lp.publish(keys.PhasesActive, lp.ActivePhases())
lp.publish(keys.SmartCostLimit, lp.smartCostLimit)
lp.publishTimer(phaseTimer, 0, timerInactive)
lp.publishTimer(pvTimer, 0, timerInactive)
@ -685,6 +709,11 @@ func (lp *Loadpoint) Prepare(uiChan chan<- util.Param, pushChan chan<- push.Even
lp.log.ERROR.Printf("charger enabled: %v", err)
}
// set vehicle polling mode
if lp.Soc.Poll.Mode != loadpoint.PollCharging {
lp.log.WARN.Printf("poll mode '%s' may deplete your battery or lead to API misuse. USE AT YOUR OWN RISK.", lp.Soc.Poll)
}
// allow charger to access loadpoint
if ctrl, ok := lp.charger.(loadpoint.Controller); ok {
ctrl.LoadpointControl(lp)

View file

@ -22,12 +22,29 @@ type API interface {
// GetStatus returns the charging status
GetStatus() api.ChargeStatus
//
// references
//
// GetCharger returns the loadpoint charger
GetChargerName() string
// GetMeter returns the loadpoint meter
GetMeterName() string
// GetCircuitName returns the loadpoint circuit name
GetCircuitName() string
// GetCircuit returns the loadpoint circuit
GetCircuit() api.Circuit
// GetDefaultVehicle returns the loadpoint default vehicle
GetDefaultVehicle() string
//
// settings
//
// Title returns the defined loadpoint title
Title() string
// GetTitle returns the loadpoint title
GetTitle() string
// SetTitle sets the loadpoint title
SetTitle(string)
// GetPriority returns the priority
GetPriority() int
// SetPriority sets the priority
@ -41,10 +58,14 @@ type API interface {
// SetMaxCurrent sets the max charging current
SetMaxCurrent(float64) error
// GetMode returns the charge mode
// GetMode returns the current charge mode
GetMode() api.ChargeMode
// SetMode sets the charge mode
SetMode(api.ChargeMode)
// GetDefaultMode returns the default charge mode (for reset)
GetDefaultMode() api.ChargeMode
// SetDefaultMode sets the default charge mode (for reset)
SetDefaultMode(api.ChargeMode)
// GetPhases returns the enabled phases
GetPhases() int
// SetPhases sets the enabled phases
@ -95,6 +116,15 @@ type API interface {
// GetPlan creates a charging plan
GetPlan(targetTime time.Time, requiredDuration time.Duration) (api.Rates, error)
// GetSocConfig returns the soc poll settings
GetSocConfig() SocConfig
// SetSocConfig sets the soc poll settings
SetSocConfig(soc SocConfig)
// GetThresholds returns the PV mode threshold settings
GetThresholds() ThresholdsConfig
// SetThresholds sets the PV mode threshold settings
SetThresholds(thresholds ThresholdsConfig)
// GetEnableThreshold gets the loadpoint enable threshold
GetEnableThreshold() float64
// SetEnableThreshold sets loadpoint enable threshold
@ -164,7 +194,4 @@ type API interface {
SetVehicle(vehicle api.Vehicle)
// StartVehicleDetection allows triggering vehicle detection for debugging purposes
StartVehicleDetection()
// GetCircuit gets the assigned circuit
GetCircuit() api.Circuit
}

83
core/loadpoint/config.go Normal file
View file

@ -0,0 +1,83 @@
package loadpoint
import (
"time"
"github.com/evcc-io/evcc/api"
"github.com/evcc-io/evcc/util"
)
type StaticConfig struct {
// static config
Charger string `json:"charger,omitempty"`
Meter string `json:"meter,omitempty"`
Circuit string `json:"circuit,omitempty"`
Vehicle string `json:"vehicle,omitempty"`
}
type DynamicConfig struct {
// dynamic config
Title string `json:"title"`
DefaultMode string `json:"defaultMode"`
Priority int `json:"priority"`
Phases int `json:"phases"`
MinCurrent float64 `json:"minCurrent"`
MaxCurrent float64 `json:"maxCurrent"`
SmartCostLimit *float64 `json:"smartCostLimit"`
PlanEnergy float64 `json:"planEnergy"`
PlanTime time.Time `json:"planTime"`
LimitEnergy float64 `json:"limitEnergy"`
LimitSoc int `json:"limitSoc"`
Thresholds ThresholdsConfig `json:"thresholds"`
Soc SocConfig `json:"soc"`
}
func SplitConfig(payload map[string]any) (DynamicConfig, map[string]any, error) {
// split static and dynamic config via mapstructure
var cc struct {
DynamicConfig `mapstructure:",squash"`
Other map[string]any `mapstructure:",remain"`
}
if err := util.DecodeOther(payload, &cc); err != nil {
return DynamicConfig{}, nil, err
}
// TODO: proper handling of id/name
delete(cc.Other, "id")
delete(cc.Other, "name")
return cc.DynamicConfig, cc.Other, nil
}
func (payload DynamicConfig) Apply(lp API) error {
lp.SetTitle(payload.Title)
lp.SetPriority(payload.Priority)
lp.SetSmartCostLimit(payload.SmartCostLimit)
lp.SetThresholds(payload.Thresholds)
lp.SetPlanEnergy(payload.PlanTime, payload.PlanEnergy)
lp.SetLimitEnergy(payload.LimitEnergy)
lp.SetLimitSoc(payload.LimitSoc)
// TODO mode warning
lp.SetSocConfig(payload.Soc)
mode, err := api.ChargeModeString(payload.DefaultMode)
if err == nil {
lp.SetDefaultMode(mode)
}
if err == nil {
err = lp.SetPhases(payload.Phases)
}
if err == nil && payload.MinCurrent != 0 {
err = lp.SetMinCurrent(payload.MinCurrent)
}
if err == nil && payload.MaxCurrent != 0 {
err = lp.SetMaxCurrent(payload.MaxCurrent)
}
return err
}

View file

@ -167,6 +167,20 @@ func (mr *MockAPIMockRecorder) GetChargePowerFlexibility() *gomock.Call {
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetChargePowerFlexibility", reflect.TypeOf((*MockAPI)(nil).GetChargePowerFlexibility))
}
// GetChargerName mocks base method.
func (m *MockAPI) GetChargerName() string {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "GetChargerName")
ret0, _ := ret[0].(string)
return ret0
}
// GetChargerName indicates an expected call of GetChargerName.
func (mr *MockAPIMockRecorder) GetChargerName() *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetChargerName", reflect.TypeOf((*MockAPI)(nil).GetChargerName))
}
// GetCircuit mocks base method.
func (m *MockAPI) GetCircuit() api.Circuit {
m.ctrl.T.Helper()
@ -181,6 +195,48 @@ func (mr *MockAPIMockRecorder) GetCircuit() *gomock.Call {
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetCircuit", reflect.TypeOf((*MockAPI)(nil).GetCircuit))
}
// GetCircuitName mocks base method.
func (m *MockAPI) GetCircuitName() string {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "GetCircuitName")
ret0, _ := ret[0].(string)
return ret0
}
// GetCircuitName indicates an expected call of GetCircuitName.
func (mr *MockAPIMockRecorder) GetCircuitName() *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetCircuitName", reflect.TypeOf((*MockAPI)(nil).GetCircuitName))
}
// GetDefaultMode mocks base method.
func (m *MockAPI) GetDefaultMode() api.ChargeMode {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "GetDefaultMode")
ret0, _ := ret[0].(api.ChargeMode)
return ret0
}
// GetDefaultMode indicates an expected call of GetDefaultMode.
func (mr *MockAPIMockRecorder) GetDefaultMode() *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetDefaultMode", reflect.TypeOf((*MockAPI)(nil).GetDefaultMode))
}
// GetDefaultVehicle mocks base method.
func (m *MockAPI) GetDefaultVehicle() string {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "GetDefaultVehicle")
ret0, _ := ret[0].(string)
return ret0
}
// GetDefaultVehicle indicates an expected call of GetDefaultVehicle.
func (mr *MockAPIMockRecorder) GetDefaultVehicle() *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetDefaultVehicle", reflect.TypeOf((*MockAPI)(nil).GetDefaultVehicle))
}
// GetDisableDelay mocks base method.
func (m *MockAPI) GetDisableDelay() time.Duration {
m.ctrl.T.Helper()
@ -293,6 +349,20 @@ func (mr *MockAPIMockRecorder) GetMaxPhaseCurrent() *gomock.Call {
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetMaxPhaseCurrent", reflect.TypeOf((*MockAPI)(nil).GetMaxPhaseCurrent))
}
// GetMeterName mocks base method.
func (m *MockAPI) GetMeterName() string {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "GetMeterName")
ret0, _ := ret[0].(string)
return ret0
}
// GetMeterName indicates an expected call of GetMeterName.
func (mr *MockAPIMockRecorder) GetMeterName() *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetMeterName", reflect.TypeOf((*MockAPI)(nil).GetMeterName))
}
// GetMinCurrent mocks base method.
func (m *MockAPI) GetMinCurrent() float64 {
m.ctrl.T.Helper()
@ -450,6 +520,20 @@ func (mr *MockAPIMockRecorder) GetSmartCostLimit() *gomock.Call {
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetSmartCostLimit", reflect.TypeOf((*MockAPI)(nil).GetSmartCostLimit))
}
// GetSocConfig mocks base method.
func (m *MockAPI) GetSocConfig() SocConfig {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "GetSocConfig")
ret0, _ := ret[0].(SocConfig)
return ret0
}
// GetSocConfig indicates an expected call of GetSocConfig.
func (mr *MockAPIMockRecorder) GetSocConfig() *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetSocConfig", reflect.TypeOf((*MockAPI)(nil).GetSocConfig))
}
// GetStatus mocks base method.
func (m *MockAPI) GetStatus() api.ChargeStatus {
m.ctrl.T.Helper()
@ -464,6 +548,34 @@ func (mr *MockAPIMockRecorder) GetStatus() *gomock.Call {
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetStatus", reflect.TypeOf((*MockAPI)(nil).GetStatus))
}
// GetThresholds mocks base method.
func (m *MockAPI) GetThresholds() ThresholdsConfig {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "GetThresholds")
ret0, _ := ret[0].(ThresholdsConfig)
return ret0
}
// GetThresholds indicates an expected call of GetThresholds.
func (mr *MockAPIMockRecorder) GetThresholds() *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetThresholds", reflect.TypeOf((*MockAPI)(nil).GetThresholds))
}
// GetTitle mocks base method.
func (m *MockAPI) GetTitle() string {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "GetTitle")
ret0, _ := ret[0].(string)
return ret0
}
// GetTitle indicates an expected call of GetTitle.
func (mr *MockAPIMockRecorder) GetTitle() *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetTitle", reflect.TypeOf((*MockAPI)(nil).GetTitle))
}
// GetVehicle mocks base method.
func (m *MockAPI) GetVehicle() api.Vehicle {
m.ctrl.T.Helper()
@ -544,6 +656,18 @@ func (mr *MockAPIMockRecorder) SetBatteryBoost(enable any) *gomock.Call {
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SetBatteryBoost", reflect.TypeOf((*MockAPI)(nil).SetBatteryBoost), enable)
}
// SetDefaultMode mocks base method.
func (m *MockAPI) SetDefaultMode(arg0 api.ChargeMode) {
m.ctrl.T.Helper()
m.ctrl.Call(m, "SetDefaultMode", arg0)
}
// SetDefaultMode indicates an expected call of SetDefaultMode.
func (mr *MockAPIMockRecorder) SetDefaultMode(arg0 any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SetDefaultMode", reflect.TypeOf((*MockAPI)(nil).SetDefaultMode), arg0)
}
// SetDisableDelay mocks base method.
func (m *MockAPI) SetDisableDelay(delay time.Duration) {
m.ctrl.T.Helper()
@ -708,6 +832,42 @@ func (mr *MockAPIMockRecorder) SetSmartCostLimit(limit any) *gomock.Call {
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SetSmartCostLimit", reflect.TypeOf((*MockAPI)(nil).SetSmartCostLimit), limit)
}
// SetSocConfig mocks base method.
func (m *MockAPI) SetSocConfig(soc SocConfig) {
m.ctrl.T.Helper()
m.ctrl.Call(m, "SetSocConfig", soc)
}
// SetSocConfig indicates an expected call of SetSocConfig.
func (mr *MockAPIMockRecorder) SetSocConfig(soc any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SetSocConfig", reflect.TypeOf((*MockAPI)(nil).SetSocConfig), soc)
}
// SetThresholds mocks base method.
func (m *MockAPI) SetThresholds(thresholds ThresholdsConfig) {
m.ctrl.T.Helper()
m.ctrl.Call(m, "SetThresholds", thresholds)
}
// SetThresholds indicates an expected call of SetThresholds.
func (mr *MockAPIMockRecorder) SetThresholds(thresholds any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SetThresholds", reflect.TypeOf((*MockAPI)(nil).SetThresholds), thresholds)
}
// SetTitle mocks base method.
func (m *MockAPI) SetTitle(arg0 string) {
m.ctrl.T.Helper()
m.ctrl.Call(m, "SetTitle", arg0)
}
// SetTitle indicates an expected call of SetTitle.
func (mr *MockAPIMockRecorder) SetTitle(arg0 any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SetTitle", reflect.TypeOf((*MockAPI)(nil).SetTitle), arg0)
}
// SetVehicle mocks base method.
func (m *MockAPI) SetVehicle(vehicle api.Vehicle) {
m.ctrl.T.Helper()
@ -745,17 +905,3 @@ func (mr *MockAPIMockRecorder) StartVehicleDetection() *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "StartVehicleDetection", reflect.TypeOf((*MockAPI)(nil).StartVehicleDetection))
}
// Title mocks base method.
func (m *MockAPI) Title() string {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "Title")
ret0, _ := ret[0].(string)
return ret0
}
// Title indicates an expected call of Title.
func (mr *MockAPIMockRecorder) Title() *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Title", reflect.TypeOf((*MockAPI)(nil).Title))
}

View file

@ -0,0 +1,94 @@
// Code generated by "enumer -type PollMode -trimprefix Poll -transform=lower -text"; DO NOT EDIT.
package loadpoint
import (
"fmt"
"strings"
)
const _PollModeName = "chargingconnectedalways"
var _PollModeIndex = [...]uint8{0, 8, 17, 23}
const _PollModeLowerName = "chargingconnectedalways"
func (i PollMode) String() string {
if i < 0 || i >= PollMode(len(_PollModeIndex)-1) {
return fmt.Sprintf("PollMode(%d)", i)
}
return _PollModeName[_PollModeIndex[i]:_PollModeIndex[i+1]]
}
// An "invalid array index" compiler error signifies that the constant values have changed.
// Re-run the stringer command to generate them again.
func _PollModeNoOp() {
var x [1]struct{}
_ = x[PollCharging-(0)]
_ = x[PollConnected-(1)]
_ = x[PollAlways-(2)]
}
var _PollModeValues = []PollMode{PollCharging, PollConnected, PollAlways}
var _PollModeNameToValueMap = map[string]PollMode{
_PollModeName[0:8]: PollCharging,
_PollModeLowerName[0:8]: PollCharging,
_PollModeName[8:17]: PollConnected,
_PollModeLowerName[8:17]: PollConnected,
_PollModeName[17:23]: PollAlways,
_PollModeLowerName[17:23]: PollAlways,
}
var _PollModeNames = []string{
_PollModeName[0:8],
_PollModeName[8:17],
_PollModeName[17:23],
}
// PollModeString retrieves an enum value from the enum constants string name.
// Throws an error if the param is not part of the enum.
func PollModeString(s string) (PollMode, error) {
if val, ok := _PollModeNameToValueMap[s]; ok {
return val, nil
}
if val, ok := _PollModeNameToValueMap[strings.ToLower(s)]; ok {
return val, nil
}
return 0, fmt.Errorf("%s does not belong to PollMode values", s)
}
// PollModeValues returns all values of the enum
func PollModeValues() []PollMode {
return _PollModeValues
}
// PollModeStrings returns a slice of all String values of the enum
func PollModeStrings() []string {
strs := make([]string, len(_PollModeNames))
copy(strs, _PollModeNames)
return strs
}
// IsAPollMode returns "true" if the value is listed in the enum definition. "false" otherwise
func (i PollMode) IsAPollMode() bool {
for _, v := range _PollModeValues {
if i == v {
return true
}
}
return false
}
// MarshalText implements the encoding.TextMarshaler interface for PollMode
func (i PollMode) MarshalText() ([]byte, error) {
return []byte(i.String()), nil
}
// UnmarshalText implements the encoding.TextUnmarshaler interface for PollMode
func (i *PollMode) UnmarshalText(text []byte) error {
var err error
*i, err = PollModeString(string(text))
return err
}

39
core/loadpoint/types.go Normal file
View file

@ -0,0 +1,39 @@
package loadpoint
import (
"time"
)
// ThresholdsConfig defines pv mode hysteresis parameters
type ThresholdsConfig struct {
Enable ThresholdConfig `json:"enable"`
Disable ThresholdConfig `json:"disable"`
}
// ThresholdConfig defines enable/disable hysteresis parameters
type ThresholdConfig struct {
Delay time.Duration `json:"delay"`
Threshold float64 `json:"threshold"`
}
// SocConfig defines soc settings, estimation and update behavior
type SocConfig struct {
Poll PollConfig `json:"poll"`
Estimate *bool `json:"estimate"`
}
// PollConfig defines the vehicle polling mode and interval
type PollConfig struct {
Mode PollMode `json:"mode"` // polling mode charging (default), connected, always
Interval time.Duration `json:"interval"` // interval when not charging
}
//go:generate enumer -type PollMode -trimprefix Poll -transform=lower -text
type PollMode int
// Poll modes
const (
PollCharging PollMode = iota
PollConnected
PollAlways
)

View file

@ -13,11 +13,50 @@ import (
var _ loadpoint.API = (*Loadpoint)(nil)
// Title returns the human-readable loadpoint title
func (lp *Loadpoint) Title() string {
// GetCharger returns the loadpoint charger
func (lp *Loadpoint) GetChargerName() string {
return lp.ChargerRef
}
// GetMeter returns the loadpoint meter
func (lp *Loadpoint) GetMeterName() string {
return lp.MeterRef
}
// GetCircuitName returns the loadpoint circuit
func (lp *Loadpoint) GetCircuitName() string {
return lp.CircuitRef
}
// GetDefaultVehicle returns the loadpoint default vehicle
func (lp *Loadpoint) GetDefaultVehicle() string {
return lp.VehicleRef
}
// GetTitle returns the loadpoint title
func (lp *Loadpoint) GetTitle() string {
lp.RLock()
defer lp.RUnlock()
return lp.Title_
return lp.title
}
// SetTitle sets the loadpoint title
func (lp *Loadpoint) SetTitle(title string) {
lp.Lock()
defer lp.Unlock()
lp.log.DEBUG.Println("set title:", title)
if title != lp.title {
lp.setTitle(title)
}
}
// setTitle sets the loadpoint title (no mutex)
func (lp *Loadpoint) setTitle(title string) {
lp.title = title
lp.publish(keys.Title, lp.title)
lp.settings.SetString(keys.Title, lp.title)
}
// GetStatus returns the charging status
@ -74,6 +113,26 @@ func (lp *Loadpoint) SetMode(mode api.ChargeMode) {
}
}
// GetDefaultMode returns the default charge mode
func (lp *Loadpoint) GetDefaultMode() api.ChargeMode {
lp.RLock()
defer lp.RUnlock()
return lp.DefaultMode
}
// SetDefaultMode sets the default charge mode
func (lp *Loadpoint) SetDefaultMode(mode api.ChargeMode) {
lp.Lock()
defer lp.Unlock()
lp.log.DEBUG.Println("set default mode:", mode)
if lp.DefaultMode != mode {
lp.DefaultMode = mode
lp.settings.SetString(keys.DefaultMode, string(mode))
}
}
// getChargedEnergy returns session charge energy in Wh
func (lp *Loadpoint) getChargedEnergy() float64 {
lp.RLock()
@ -85,7 +144,14 @@ func (lp *Loadpoint) getChargedEnergy() float64 {
func (lp *Loadpoint) GetPriority() int {
lp.RLock()
defer lp.RUnlock()
return lp.Priority_
return lp.priority
}
// setPriority sets the loadpoint priority (no mutex)
func (lp *Loadpoint) setPriority(prio int) {
lp.priority = prio
lp.publish(keys.Priority, lp.priority)
lp.settings.SetInt(keys.Priority, int64(lp.priority))
}
// SetPriority sets the loadpoint priority
@ -96,8 +162,7 @@ func (lp *Loadpoint) SetPriority(prio int) {
lp.log.DEBUG.Println("set priority:", prio)
if lp.Priority_ != prio {
lp.Priority_ = prio
lp.publish(keys.Priority, prio)
lp.setPriority(prio)
}
}
@ -112,7 +177,7 @@ func (lp *Loadpoint) GetPhases() int {
func (lp *Loadpoint) SetPhases(phases int) error {
// limit auto mode (phases=0) to scalable charger
if !lp.hasPhaseSwitching() && phases == 0 {
return fmt.Errorf("invalid number of phases: %d", phases)
return fmt.Errorf("charger does not support phase switching")
}
if phases != 0 && phases != 1 && phases != 3 {
@ -239,6 +304,60 @@ func (lp *Loadpoint) SetPlanEnergy(finishAt time.Time, energy float64) error {
return nil
}
// GetSoc returns the PV mode threshold settings
func (lp *Loadpoint) GetSocConfig() loadpoint.SocConfig {
lp.RLock()
defer lp.RUnlock()
return lp.Soc
}
func (lp *Loadpoint) setSocConfig(soc loadpoint.SocConfig) {
lp.Soc = soc
lp.settings.SetJson(keys.Soc, soc)
lp.requestUpdate()
}
// SetSoc sets the PV mode threshold settings
func (lp *Loadpoint) SetSocConfig(soc loadpoint.SocConfig) {
lp.Lock()
defer lp.Unlock()
lp.log.DEBUG.Printf("set soc config: %+v", soc)
// apply immediately
lp.setSocConfig(soc)
}
// GetThresholds returns the PV mode threshold settings
func (lp *Loadpoint) GetThresholds() loadpoint.ThresholdsConfig {
lp.RLock()
defer lp.RUnlock()
return loadpoint.ThresholdsConfig{
Enable: lp.Enable,
Disable: lp.Disable,
}
}
func (lp *Loadpoint) setThresholds(thresholds loadpoint.ThresholdsConfig) {
lp.Enable = thresholds.Enable
lp.Disable = thresholds.Disable
lp.publish(keys.EnableThreshold, lp.Enable.Threshold)
lp.publish(keys.DisableThreshold, lp.Disable.Threshold)
lp.settings.SetJson(keys.Thresholds, thresholds)
lp.requestUpdate()
}
// SetThresholds sets the PV mode threshold settings
func (lp *Loadpoint) SetThresholds(thresholds loadpoint.ThresholdsConfig) {
lp.Lock()
defer lp.Unlock()
lp.log.DEBUG.Printf("set thresholds: %+v", thresholds)
// apply immediately
lp.setThresholds(thresholds)
}
// GetEnableThreshold gets the loadpoint enable threshold
func (lp *Loadpoint) GetEnableThreshold() float64 {
lp.RLock()
@ -255,7 +374,11 @@ func (lp *Loadpoint) SetEnableThreshold(threshold float64) {
if lp.Enable.Threshold != threshold {
lp.Enable.Threshold = threshold
lp.publish(keys.EnableThreshold, threshold)
// TODO reduce APIs
lp.setThresholds(loadpoint.ThresholdsConfig{
Enable: lp.Enable,
Disable: lp.Disable,
})
}
}
@ -275,7 +398,11 @@ func (lp *Loadpoint) SetDisableThreshold(threshold float64) {
if lp.Disable.Threshold != threshold {
lp.Disable.Threshold = threshold
lp.publish(keys.DisableThreshold, threshold)
// TODO reduce APIs
lp.setThresholds(loadpoint.ThresholdsConfig{
Enable: lp.Enable,
Disable: lp.Disable,
})
}
}

View file

@ -8,8 +8,9 @@ import (
// setConfiguredPhases sets the default phase configuration
func (lp *Loadpoint) setConfiguredPhases(phases int) {
lp.configuredPhases = phases
lp.publish(keys.PhasesConfigured, lp.configuredPhases)
lp.settings.SetInt(keys.PhasesConfigured, int64(lp.configuredPhases))
lp.publish(keys.Phases, lp.configuredPhases)
lp.publish(keys.PhasesConfigured, lp.configuredPhases) // TODO remove
lp.settings.SetInt(keys.Phases, int64(lp.configuredPhases))
}
// setPhases sets the number of enabled phases without modifying the charger

View file

@ -8,6 +8,7 @@ import (
evbus "github.com/asaskevich/EventBus"
"github.com/benbjohnson/clock"
"github.com/evcc-io/evcc/api"
"github.com/evcc-io/evcc/core/loadpoint"
"github.com/evcc-io/evcc/util"
"github.com/stretchr/testify/require"
"go.uber.org/mock/gomock"
@ -405,10 +406,10 @@ func TestPvScalePhasesTimer(t *testing.T) {
phases: tc.phases,
measuredPhases: tc.measuredPhases,
status: api.StatusC,
Enable: ThresholdConfig{
Enable: loadpoint.ThresholdConfig{
Delay: dt,
},
Disable: ThresholdConfig{
Disable: loadpoint.ThresholdConfig{
Delay: dt,
},
}

View file

@ -7,6 +7,8 @@ import (
evbus "github.com/asaskevich/EventBus"
"github.com/benbjohnson/clock"
"github.com/evcc-io/evcc/api"
"github.com/evcc-io/evcc/core/loadpoint"
"github.com/evcc-io/evcc/core/settings"
"github.com/evcc-io/evcc/core/soc"
"github.com/evcc-io/evcc/push"
"github.com/evcc-io/evcc/util"
@ -151,14 +153,14 @@ func TestUpdatePowerZero(t *testing.T) {
for _, tc := range tc {
t.Log(tc)
clck := clock.NewMock()
clock := clock.NewMock()
ctrl := gomock.NewController(t)
charger := api.NewMockCharger(ctrl)
lp := &Loadpoint{
log: util.NewLogger("foo"),
bus: evbus.New(),
clock: clck,
clock: clock,
charger: charger,
chargeMeter: &Null{}, // silence nil panics
chargeRater: &Null{}, // silence nil panics
@ -302,24 +304,24 @@ func TestPVHysteresis(t *testing.T) {
for _, tc := range tc {
t.Log(tc)
clck := clock.NewMock()
clock := clock.NewMock()
ctrl := gomock.NewController(t)
charger := api.NewMockCharger(ctrl)
Voltage = 100
lp := &Loadpoint{
log: util.NewLogger("foo"),
clock: clck,
clock: clock,
charger: charger,
minCurrent: minA,
maxCurrent: maxA,
phases: phases,
measuredPhases: phases,
Enable: ThresholdConfig{
Enable: loadpoint.ThresholdConfig{
Threshold: tc.enable,
Delay: dt,
},
Disable: ThresholdConfig{
Disable: loadpoint.ThresholdConfig{
Threshold: tc.disable,
Delay: dt,
},
@ -328,10 +330,10 @@ func TestPVHysteresis(t *testing.T) {
// charging, otherwise PV mode logic is short-circuited
lp.status = status
start := clck.Now()
start := clock.Now()
for step, se := range tc.series {
clck.Set(start.Add(se.delay))
clock.Set(start.Add(se.delay))
// maxCurrent will read actual current and enabled state in PV mode
// charger.EXPECT().Enabled().Return(tc.enabled, nil)
@ -352,13 +354,13 @@ func TestPVHysteresis(t *testing.T) {
func TestPVHysteresisForStatusOtherThanC(t *testing.T) {
const phases = 3
clck := clock.NewMock()
clock := clock.NewMock()
ctrl := gomock.NewController(t)
Voltage = 100
lp := &Loadpoint{
log: util.NewLogger("foo"),
clock: clck,
clock: clock,
minCurrent: minA,
maxCurrent: maxA,
phases: phases,
@ -407,9 +409,9 @@ func TestDisableAndEnableAtTargetSoc(t *testing.T) {
socEstimator: socEstimator, // instead of vehicle: vehicle,
mode: api.ModeNow,
limitSoc: 90, // session limit
Soc: SocConfig{
Poll: PollConfig{
Mode: pollConnected, // allow polling when connected
Soc: loadpoint.SocConfig{
Poll: loadpoint.PollConfig{
Mode: loadpoint.PollConnected, // allow polling when connected
Interval: pollInterval,
},
},
@ -473,6 +475,7 @@ func TestSetModeAndSocAtDisconnect(t *testing.T) {
log: util.NewLogger("foo"),
bus: evbus.New(),
clock: clock,
settings: settings.NewDatabaseSettingsAdapter("foo"),
charger: charger,
chargeMeter: &Null{}, // silence nil panics
chargeRater: &Null{}, // silence nil panics
@ -481,7 +484,7 @@ func TestSetModeAndSocAtDisconnect(t *testing.T) {
minCurrent: minA,
maxCurrent: maxA,
status: api.StatusC,
Mode_: api.ModeOff, // default mode
DefaultMode: api.ModeOff, // default mode
}
attachListeners(t, lp)
@ -616,59 +619,59 @@ func TestSocPoll(t *testing.T) {
lp := &Loadpoint{
clock: clock,
log: util.NewLogger("foo"),
Soc: SocConfig{
Poll: PollConfig{
Soc: loadpoint.SocConfig{
Poll: loadpoint.PollConfig{
Interval: time.Hour,
},
},
}
tc := []struct {
mode string
mode loadpoint.PollMode
status api.ChargeStatus
dt time.Duration
res bool
}{
// pollCharging
{pollCharging, api.StatusA, -1, false},
{pollCharging, api.StatusA, 0, false},
{pollCharging, api.StatusA, tRefresh, false},
{pollCharging, api.StatusB, -1, true}, // poll once when car connected
{pollCharging, api.StatusB, 0, false},
{pollCharging, api.StatusB, tRefresh, false},
{pollCharging, api.StatusC, -1, true},
{pollCharging, api.StatusC, 0, true},
{pollCharging, api.StatusC, tNoRefresh, true}, // cached by vehicle
{pollCharging, api.StatusB, -1, true}, // fetch if car stopped charging
{pollCharging, api.StatusB, 0, false}, // no more polling
{pollCharging, api.StatusB, tRefresh, false}, // no more polling
{loadpoint.PollCharging, api.StatusA, -1, false},
{loadpoint.PollCharging, api.StatusA, 0, false},
{loadpoint.PollCharging, api.StatusA, tRefresh, false},
{loadpoint.PollCharging, api.StatusB, -1, true}, // poll once when car connected
{loadpoint.PollCharging, api.StatusB, 0, false},
{loadpoint.PollCharging, api.StatusB, tRefresh, false},
{loadpoint.PollCharging, api.StatusC, -1, true},
{loadpoint.PollCharging, api.StatusC, 0, true},
{loadpoint.PollCharging, api.StatusC, tNoRefresh, true}, // cached by vehicle
{loadpoint.PollCharging, api.StatusB, -1, true}, // fetch if car stopped charging
{loadpoint.PollCharging, api.StatusB, 0, false}, // no more polling
{loadpoint.PollCharging, api.StatusB, tRefresh, false}, // no more polling
// pollConnected
{pollConnected, api.StatusA, -1, false},
{pollConnected, api.StatusA, 0, false},
{pollConnected, api.StatusA, tRefresh, false},
{pollConnected, api.StatusB, -1, true},
{pollConnected, api.StatusB, 0, false},
{pollConnected, api.StatusB, tNoRefresh, false},
{pollConnected, api.StatusB, tRefresh, true},
{pollConnected, api.StatusC, -1, true},
{pollConnected, api.StatusC, 0, true},
{pollConnected, api.StatusC, tNoRefresh, true}, // cached by vehicle
{pollConnected, api.StatusC, tRefresh, true},
{loadpoint.PollConnected, api.StatusA, -1, false},
{loadpoint.PollConnected, api.StatusA, 0, false},
{loadpoint.PollConnected, api.StatusA, tRefresh, false},
{loadpoint.PollConnected, api.StatusB, -1, true},
{loadpoint.PollConnected, api.StatusB, 0, false},
{loadpoint.PollConnected, api.StatusB, tNoRefresh, false},
{loadpoint.PollConnected, api.StatusB, tRefresh, true},
{loadpoint.PollConnected, api.StatusC, -1, true},
{loadpoint.PollConnected, api.StatusC, 0, true},
{loadpoint.PollConnected, api.StatusC, tNoRefresh, true}, // cached by vehicle
{loadpoint.PollConnected, api.StatusC, tRefresh, true},
// pollAlways
{pollAlways, api.StatusA, -1, true},
{pollAlways, api.StatusA, 0, false},
{pollAlways, api.StatusA, tNoRefresh, false},
{pollAlways, api.StatusA, tRefresh, true},
{pollAlways, api.StatusB, -1, true},
{pollAlways, api.StatusB, 0, false},
{pollAlways, api.StatusB, tNoRefresh, false},
{pollAlways, api.StatusB, tRefresh, true},
{pollAlways, api.StatusC, -1, true},
{pollAlways, api.StatusC, 0, true},
{pollAlways, api.StatusC, tNoRefresh, true}, // cached by vehicle
{pollAlways, api.StatusC, tRefresh, true},
{loadpoint.PollAlways, api.StatusA, -1, true},
{loadpoint.PollAlways, api.StatusA, 0, false},
{loadpoint.PollAlways, api.StatusA, tNoRefresh, false},
{loadpoint.PollAlways, api.StatusA, tRefresh, true},
{loadpoint.PollAlways, api.StatusB, -1, true},
{loadpoint.PollAlways, api.StatusB, 0, false},
{loadpoint.PollAlways, api.StatusB, tNoRefresh, false},
{loadpoint.PollAlways, api.StatusB, tRefresh, true},
{loadpoint.PollAlways, api.StatusC, -1, true},
{loadpoint.PollAlways, api.StatusC, 0, true},
{loadpoint.PollAlways, api.StatusC, tNoRefresh, true}, // cached by vehicle
{loadpoint.PollAlways, api.StatusC, tRefresh, true},
}
for _, tc := range tc {
@ -723,7 +726,7 @@ func TestPVHysteresisAfterPhaseSwitch(t *testing.T) {
for _, tc := range tc {
t.Log(tc)
clck := clock.NewMock()
clock := clock.NewMock()
ctrl := gomock.NewController(t)
switcher := api.NewMockPhaseSwitcher(ctrl)
@ -739,21 +742,21 @@ func TestPVHysteresisAfterPhaseSwitch(t *testing.T) {
Voltage = 100
lp := &Loadpoint{
log: util.NewLogger("foo"),
clock: clck,
clock: clock,
charger: charger,
minCurrent: minA,
maxCurrent: maxA,
Disable: ThresholdConfig{
Disable: loadpoint.ThresholdConfig{
Delay: dt,
},
status: api.StatusC,
enabled: true,
}
start := clck.Now()
start := clock.Now()
for step, se := range tc.series {
clck.Set(start.Add(se.delay))
clock.Set(start.Add(se.delay))
assert.Equal(t, se.current, lp.pvMaxCurrent(api.ModePV, se.site, 0, false, false), step)
}

View file

@ -314,9 +314,9 @@ func (lp *Loadpoint) vehicleOdometer() {
// vehicleClimatePollAllowed determines if polling depending on mode and connection status
func (lp *Loadpoint) vehicleClimatePollAllowed() bool {
switch {
case lp.Soc.Poll.Mode == pollCharging && lp.charging():
case lp.Soc.Poll.Mode == loadpoint.PollCharging && lp.charging():
return true
case (lp.Soc.Poll.Mode == pollConnected || lp.Soc.Poll.Mode == pollAlways) && lp.connected():
case (lp.Soc.Poll.Mode == loadpoint.PollConnected || lp.Soc.Poll.Mode == loadpoint.PollAlways) && lp.connected():
return true
default:
return false
@ -337,8 +337,8 @@ func (lp *Loadpoint) vehicleSocPollAllowed() bool {
remaining := lp.Soc.Poll.Interval - lp.clock.Since(lp.socUpdated)
honourUpdateInterval := lp.Soc.Poll.Mode == pollAlways ||
lp.connected() && lp.Soc.Poll.Mode == pollConnected
honourUpdateInterval := lp.Soc.Poll.Mode == loadpoint.PollAlways ||
lp.connected() && lp.Soc.Poll.Mode == loadpoint.PollConnected
if honourUpdateInterval {
if remaining > 0 {

View file

@ -9,6 +9,7 @@ import (
"github.com/benbjohnson/clock"
"github.com/evcc-io/evcc/api"
"github.com/evcc-io/evcc/core/coordinator"
"github.com/evcc-io/evcc/core/settings"
"github.com/evcc-io/evcc/core/soc"
"github.com/evcc-io/evcc/util"
"github.com/stretchr/testify/assert"
@ -171,8 +172,8 @@ func TestDefaultVehicle(t *testing.T) {
vehicle.EXPECT().Phases().AnyTimes()
vehicle.EXPECT().OnIdentified().AnyTimes()
lp := NewLoadpoint(util.NewLogger("foo"), nil)
lp.Mode_ = api.ModeOff // ondisconnect
lp := NewLoadpoint(util.NewLogger("foo"), settings.NewDatabaseSettingsAdapter("foo"))
lp.DefaultMode = api.ModeOff // ondisconnect
lp.defaultVehicle = dflt
// populate channels

View file

@ -40,12 +40,12 @@ func (p *Prioritizer) GetChargePowerFlexibility(lp loadpoint.API) float64 {
for lp, power := range p.demand {
if lp.EffectivePriority() < prio && power > 0 {
reduceBy += power
msg += fmt.Sprintf("%.0fW from %s at prio %d, ", power, lp.Title(), lp.EffectivePriority())
msg += fmt.Sprintf("%.0fW from %s at prio %d, ", power, lp.GetTitle(), lp.EffectivePriority())
}
}
if p.log != nil && reduceBy > 0 {
p.log.DEBUG.Printf("lp %s at prio %d gets additional %stotal %.0fW\n", lp.Title(), lp.EffectivePriority(), msg, reduceBy)
p.log.DEBUG.Printf("lp %s at prio %d gets additional %stotal %.0fW\n", lp.GetTitle(), lp.EffectivePriority(), msg, reduceBy)
}
return reduceBy

View file

@ -14,12 +14,12 @@ func TestPrioritzer(t *testing.T) {
p := New(nil)
lo := loadpoint.NewMockAPI(ctrl)
lo.EXPECT().Title().AnyTimes()
lo.EXPECT().GetTitle().AnyTimes()
lo.EXPECT().GetPriority().Return(0).AnyTimes() // prio 0
lo.EXPECT().EffectivePriority().Return(0).AnyTimes() // prio 0
hi := loadpoint.NewMockAPI(ctrl)
hi.EXPECT().Title().AnyTimes()
hi.EXPECT().GetTitle().AnyTimes()
hi.EXPECT().GetPriority().Return(1).AnyTimes() // prio 1
hi.EXPECT().EffectivePriority().Return(1).AnyTimes() // prio 1

View file

@ -1,107 +0,0 @@
package core
import (
"time"
"github.com/evcc-io/evcc/server/db/settings"
)
// type Settings interface {
// SetString(val string)
// SetInt(val int64)
// SetFloat(val float64)
// SetTime(val time.Time)
// SetJson(val any) error
// SetBool(val bool)
// String(string, error)
// Int(int64, error)
// Float(float64, error)
// Time(time.Time, error)
// Bool(bool, error)
// Json(res any) error
// }
type Settings struct {
Key string
}
func (s *Settings) SetString(key string, val string) {
if s == nil {
return
}
settings.SetString(s.Key+key, val)
}
func (s *Settings) SetInt(key string, val int64) {
if s == nil {
return
}
settings.SetInt(s.Key+key, val)
}
func (s *Settings) SetFloat(key string, val float64) {
if s == nil {
return
}
settings.SetFloat(s.Key+key, val)
}
func (s *Settings) SetTime(key string, val time.Time) {
if s == nil {
return
}
settings.SetTime(s.Key+key, val)
}
func (s *Settings) SetBool(key string, val bool) {
if s == nil {
return
}
settings.SetBool(s.Key+key, val)
}
// func (s *Settings) SetJson(key string, val any) error {
// if s == nil {
// return nil
// }
// return settings.SetJson(s.Key+key, val)
// }
func (s *Settings) String(key string) (string, error) {
if s == nil {
return "", nil
}
return settings.String(s.Key + key)
}
func (s *Settings) Int(key string) (int64, error) {
if s == nil {
return 0, nil
}
return settings.Int(s.Key + key)
}
func (s *Settings) Float(key string) (float64, error) {
if s == nil {
return 0, nil
}
return settings.Float(s.Key + key)
}
func (s *Settings) Time(key string) (time.Time, error) {
if s == nil {
return time.Time{}, nil
}
return settings.Time(s.Key + key)
}
func (s *Settings) Bool(key string) (bool, error) {
if s == nil {
return false, nil
}
return settings.Bool(s.Key + key)
}
// func (s *Settings) Json(key string, res any) error {
// return settings.Json(s.Key+key, &res)
// }

111
core/settings/config.go Normal file
View file

@ -0,0 +1,111 @@
package settings
import (
"encoding/json"
"errors"
"time"
"github.com/evcc-io/evcc/util"
"github.com/evcc-io/evcc/util/config"
"github.com/spf13/cast"
)
var _ Settings = (*ConfigSettings)(nil)
type ConfigSettings struct {
log *util.Logger
conf *config.Config
}
func NewConfigSettingsAdapter(log *util.Logger, conf *config.Config) *ConfigSettings {
return &ConfigSettings{log, conf}
}
func (s *ConfigSettings) get(key string) (any, error) {
val := s.conf.Named().Other[key]
if val == nil {
return nil, errors.New("not found")
}
return val, nil
}
func (s *ConfigSettings) set(key string, val any) {
data := s.conf.Named().Other
data[key] = val
if err := s.conf.PartialUpdate(data); err != nil {
s.log.ERROR.Println(err)
}
}
func (s *ConfigSettings) SetString(key string, val string) {
s.set(key, val)
}
func (s *ConfigSettings) SetInt(key string, val int64) {
s.set(key, val)
}
func (s *ConfigSettings) SetFloat(key string, val float64) {
s.set(key, val)
}
func (s *ConfigSettings) SetTime(key string, val time.Time) {
s.set(key, val)
}
func (s *ConfigSettings) SetBool(key string, val bool) {
s.set(key, val)
}
func (s *ConfigSettings) SetJson(key string, val any) error {
s.set(key, val)
return nil
}
func (s *ConfigSettings) String(key string) (string, error) {
val, err := s.get(key)
if err != nil {
return "", err
}
return cast.ToStringE(val)
}
func (s *ConfigSettings) Int(key string) (int64, error) {
val, err := s.get(key)
if err != nil {
return 0, err
}
return cast.ToInt64E(val)
}
func (s *ConfigSettings) Float(key string) (float64, error) {
val, err := s.get(key)
if err != nil {
return 0, err
}
return cast.ToFloat64E(val)
}
func (s *ConfigSettings) Time(key string) (time.Time, error) {
val, err := s.get(key)
if err != nil {
return time.Time{}, err
}
return cast.ToTimeE(val)
}
func (s *ConfigSettings) Bool(key string) (bool, error) {
val, err := s.get(key)
if err != nil {
return false, err
}
return cast.ToBoolE(val)
}
func (s *ConfigSettings) Json(key string, res any) error {
str, err := s.String(key)
if str == "" || err != nil {
return err
}
return json.Unmarshal([]byte(str), &res)
}

65
core/settings/database.go Normal file
View file

@ -0,0 +1,65 @@
package settings
import (
"time"
db "github.com/evcc-io/evcc/server/db/settings"
)
var _ Settings = (*dbSettings)(nil)
type dbSettings struct {
Key string
}
func NewDatabaseSettingsAdapter(key string) Settings {
return &dbSettings{key}
}
func (s *dbSettings) SetString(key string, val string) {
db.SetString(s.Key+key, val)
}
func (s *dbSettings) SetInt(key string, val int64) {
db.SetInt(s.Key+key, val)
}
func (s *dbSettings) SetFloat(key string, val float64) {
db.SetFloat(s.Key+key, val)
}
func (s *dbSettings) SetTime(key string, val time.Time) {
db.SetTime(s.Key+key, val)
}
func (s *dbSettings) SetBool(key string, val bool) {
db.SetBool(s.Key+key, val)
}
func (s *dbSettings) SetJson(key string, val any) error {
return db.SetJson(s.Key+key, val)
}
func (s *dbSettings) String(key string) (string, error) {
return db.String(s.Key + key)
}
func (s *dbSettings) Int(key string) (int64, error) {
return db.Int(s.Key + key)
}
func (s *dbSettings) Float(key string) (float64, error) {
return db.Float(s.Key + key)
}
func (s *dbSettings) Time(key string) (time.Time, error) {
return db.Time(s.Key + key)
}
func (s *dbSettings) Bool(key string) (bool, error) {
return db.Bool(s.Key + key)
}
func (s *dbSettings) Json(key string, res any) error {
return db.Json(s.Key+key, &res)
}

18
core/settings/settings.go Normal file
View file

@ -0,0 +1,18 @@
package settings
import "time"
type Settings interface {
SetString(key string, val string)
SetInt(key string, val int64)
SetFloat(key string, val float64)
SetTime(key string, val time.Time)
SetJson(key string, val any) error
SetBool(key string, val bool)
String(key string) (string, error)
Int(key string) (int64, error)
Float(key string) (float64, error)
Time(key string) (time.Time, error)
Bool(key string) (bool, error)
Json(key string, res any) error
}

View file

@ -162,7 +162,7 @@ func (site *Site) Boot(log *util.Logger, loadpoints []*Loadpoint, tariffs *tarif
if db.Instance != nil {
var err error
if lp.db, err = session.NewStore(lp.Title(), db.Instance); err != nil {
if lp.db, err = session.NewStore(lp.GetTitle(), db.Instance); err != nil {
return err
}
// Fix any dangling history

View file

@ -375,7 +375,7 @@ func (s *SEMP) deviceInfo(id int, lp loadpoint.API) DeviceInfo {
res := DeviceInfo{
Identification: Identification{
DeviceID: s.deviceID(id),
DeviceName: lp.Title(),
DeviceName: lp.GetTitle(),
DeviceType: sempCharger,
DeviceSerial: s.serialNumber(id),
DeviceVendor: "github.com/evcc-io/evcc",

View file

@ -27,6 +27,20 @@ never = "nur mit genug PV-Überschuss."
titleAdd = "Batterie hinzufügen"
titleEdit = "Batterie bearbeiten"
[config.charge]
titleAdd = "Energiezähler hinzufügen"
titleEdit = "Energiezähler bearbeiten"
[config.charger]
chargers = "Wallboxen"
generic = "Generische Integrationen"
ocppHelp = "Kopiere diese Adresse in die Konfiguration der Wallbox."
ocppLabel = "OCPP-Server URL"
switchsockets = "Schaltbare Steckdosen"
template = "Hersteller"
titleAdd = "Wallbox hinzufügen"
titleEdit = "Wallbox bearbeiten"
[config.circuits]
description = "Stellt sicher, dass die Summe aller Ladepunkte, die an einen Stromkreis angeschlossen sind, die konfigurierten Leistungs- und Stromgrenzen nicht überschreitet. Stromkreise können verschachtelt werden, um eine Hierarchie aufzubauen."
title = "Lastmanagement"
@ -48,8 +62,6 @@ chargeStatus = "Status"
chargeStatusA = "nicht verbunden"
chargeStatusB = "verbunden"
chargeStatusC = "lädt"
chargeStatusE = "keine Leistung"
chargeStatusF = "Fehler"
chargedEnergy = "Geladen"
co2 = "Netz-CO₂"
configured = "Konfiguriert"
@ -68,9 +80,11 @@ org = "Organisation"
phaseCurrents = "Strom L1, L2, L3"
phasePowers = "Leistung L1, L2, L3"
phaseVoltages = "Spannung L1, L2, L3"
phases1p3p = "Phasenumschaltung"
power = "Leistung"
powerRange = "Leistung"
range = "Reichweite"
singlePhase = "Einphasig"
soc = "SoC"
socLimit = "SoC Begrenzung"
temp = "Temperatur"
@ -78,6 +92,11 @@ topic = "Thema"
url = "URL"
yes = "Ja"
[config.deviceValueChargeStatus]
A = "A (nicht verbunden)"
B = "B (verbunden)"
C = "C (lädt)"
[config.eebus]
description = "Grundkonfiguration für die Kommunikation mit anderen EEBus-Geräten."
title = "EEBus"
@ -88,18 +107,20 @@ optional = "optional"
[config.general]
cancel = "Abbrechen"
delete = "Löschen"
docsLink = "Siehe Dokumentation."
experimental = "Experimentell"
hideAdvancedSettings = "Erweiterte einstellungen ausblenden"
hideAdvancedSettings = "Erweiterte Einstellungen ausblenden"
off = "aus"
on = "an"
password = "Passwort"
readFromFile = "Aus Datei lesen"
remove = "Entfernen"
save = "Speichern"
showAdvancedSettings = "Erweiterte einstellungen anzeigen"
showAdvancedSettings = "Erweiterte Einstellungen anzeigen"
telemetry = "Telemetrie"
title = "Titel"
validateSave = "Überprüfen & speichern"
[config.grid]
title = "Netzzähler"
@ -126,9 +147,84 @@ title = "InfluxDB"
v1Support = "Unterstützung für InfluxDB 1.x?"
v2Support = "Zurück zu InfluxDB 2.x"
[config.loadpoint]
addCharger = "Wallbox hinzufügen"
addMeter = "Zusätzlichen Zähler hinzufügen"
cancel = "Abbrechen"
chargerLabel = "Wallbox"
chargerPower11kw = "11 kW"
chargerPower11kwHelp = "Verwendet einen Strombereich von 6 bis 16 A."
chargerPower22kw = "22 kW"
chargerPower22kwHelp = "Verwendet einen Strombereich von 6 bis 32 A."
chargerPowerCustom = "andere"
chargerPowerCustomHelp = "Definiere einen eigenen Strombereich."
chargerTypeLabel = "Ladeleistung"
chargingTitle = "Laden"
circuitLabel = "Stromkreis"
circuitUnassigned = "nicht zugewiesen"
defaultModeHelp = "Lademodus beim Anschließen des Fahrzeugs."
defaultModeHelpKeep = "Der letzte ausgewählte Modus wird beibehalten."
defaultModeLabel = "Standard-Modus"
delete = "Löschen"
electricalSubtitle = "ggf. Elektriker fragen"
electricalTitle = "Elektrisch"
energyMeterHelp = "Zusätzlicher Zähler, wenn die Wallbox keinen integrierten hat."
energyMeterLabel = "Energiezähler"
estimateLabel = "Ladestand zwischen API-Updates interpolieren"
maxCurrentHelp = "Muss größer als der minimale Strom sein."
maxCurrentLabel = "Maximaler Strom"
minCurrentHelp = "Gehe nur unter 6 A, wenn du weißt, was du tust."
minCurrentLabel = "Minimaler Strom"
noVehicles = "Keine Fahrzeuge konfiguriert."
phases1p = "1-phasig"
phases3p = "3-phasig"
phasesAutomatic = "Automatische Phasen"
phasesAutomaticHelp = "Deine Wallbox unterstützt automatisches Umschalten zwischen 1- und 3-phasigem Laden. In der Hauptansicht kannst du das Phasenverhalten während des Ladens anpassen."
phasesHelp = "Anzahl der an die Wallbox angeschlossenen Phasen."
phasesLabel = "Phasen"
pollIntervalHelp = "Zeit zwischen Fahrzeug-API-Updates. Kurze Intervalle können die Fahrzeugbatterie belasten."
pollIntervalLabel = "Update-Intervall"
pollModeAlways = "immer"
pollModeAlwaysHelp = "Statusabfragen immer in regelmäßigen Abständen durchführen."
pollModeCharging = "nur beim Laden"
pollModeChargingHelp = "Fahrzeugstatus nur während des Ladens abfragen."
pollModeConnected = "wenn verbunden"
pollModeConnectedHelp = "Fahrzeugstatus in regelmäßigen Abständen abfragen, wenn verbunden."
pollModeLabel = "Update-Verhalten"
priorityHelp = "Ladepunkte mit höherer Priorität haben Vorrang beim PV-Überschuss."
priorityLabel = "Priorität"
save = "Speichern"
showAllSettings = "Alle Einstellungen anzeigen"
solarBehaviorCustomHelp = "Definiere eigene Ein- und Ausschaltschwellen und Verzögerungen."
solarBehaviorDefaultHelp = "Nur mit PV-Überschuss laden. Start nach {enableDelay} Überschuss. Stop wenn für {disableDelay} nicht genug Überschuss vorhanden ist."
solarBehaviorLabel = "PV-Verhalten"
solarModeCustom = "eigenes"
solarModeMaximum = "nur Sonne"
thresholdDisableDelayLabel = "Ausschaltverzögerung"
thresholdDisableHelpInvalid = "Bitte verwende einen positiven Wert."
thresholdDisableHelpPositive = "Laden stoppen, wenn mehr als {power} für {delay} aus dem Netz bezogen wird."
thresholdDisableHelpZero = "Stoppen, wenn minimale Ladeleistung für {delay} nicht erreicht werden kann."
thresholdDisableLabel = "Netzleistung ausschalten"
thresholdEnableDelayLabel = "Einschaltverzögerung"
thresholdEnableHelpInvalid = "Bitte verwende einen negativen Wert."
thresholdEnableHelpNegative = "Laden starten, wenn {surplus} Überschuss für {delay} verfügbar ist."
thresholdEnableHelpZero = "Starten, wenn minimale Ladeleistung für {delay} als Überschuss verfügbar ist."
thresholdEnableLabel = "Netzleistung einschalten"
titleAdd = "Ladepunkt hinzufügen"
titleEdit = "Ladepunkt bearbeiten"
titleExample = "Garage, Carport, etc."
titleLabel = "Titel"
vehicleAutoDetection = "automatische Erkennung"
vehicleHelpAutoDetection = "Wählt automatisch das plausibelste Fahrzeug aus. Manuelle Übersteuerung möglich."
vehicleHelpDefault = "Nimmt immer an, dass dieses Fahrzeug hier lädt. Auto-Erkennung deaktiviert. Manuelle Übersteuerung möglich."
vehicleLabel = "Standard-Fahrzeug"
vehiclesTitle = "Fahrzeuge"
[config.main]
addGrid = "Netzzähler hinzufügen"
addLoadpoint = "Ladepunkt hinzufügen"
addPvBattery = "PV oder Speicher hinzufügen"
addTariffs = "Tarife hinzufügen"
addVehicle = "Fahrzeug hinzufügen"
configured = "konfiguriert"
edit = "bearbeiten"
@ -195,6 +291,11 @@ little = "little-endian"
http = "HTTP (unverschlüsselt)"
https = "HTTPS (verschlüsselt)"
[config.options.status]
A = "A (nicht verbunden)"
B = "B (verbunden)"
C = "C (lädt)"
[config.pv]
titleAdd = "Zähler (PV) hinzufügen"
titleEdit = "Zähler (PV) bearbeiten"
@ -209,13 +310,16 @@ system = "System"
vehicles = "Fahrzeuge"
[config.sponsor]
addToken = "Sponsortoken eingeben"
changeToken = "Sponsortoken ändern"
addToken = "Token eingeben"
changeToken = "Token ändern"
description = "Das Sponsoring-Modell hilft uns, das Projekt zu pflegen und nachhaltig neue und spannende Funktionen zu entwickeln. Als Sponsor erhältst du Zugriff auf alle Wallbox-Implementierungen."
descriptionToken = "Du erhältst das Token von {url}. Wir bieten auch ein Testtoken für Tests an."
error = "Das Sponsortoken ist ungültig."
labelToken = "Sponsortoken"
title = "Sponsorship"
tokenRequired = "Du musst ein Sponsortoken konfigurieren, bevor du dieses Gerät anlegen kannst."
tokenRequiredLearnMore = "Mehr erfahren."
trialToken = "Testtoken"
[config.system]
logs = "Logs"
@ -293,7 +397,7 @@ thisYear = "dieses Jahr"
total = "gesamt"
[footer.sponsor]
becomeSponsor = "Werden Sie Sponsor"
becomeSponsor = "Werde Sponsor"
becomeSponsorExtended = "Unterstütze uns direkt. Es gibt auch Sticker."
confetti = "Lust auf Konfetti?"
confettiPromise = "Es gibt auch Sticker und digitales Konfetti"
@ -381,7 +485,9 @@ reset = "Passwort zurücksetzen?"
title = "Authentifizierung"
[main]
startConfiguration = "Konfiguration beginnen"
vehicles = "Parkplatz"
welcome = "Herzlich willkommen!"
[main.chargingPlan]
active = "Aktiv"
@ -629,16 +735,6 @@ csvTotal = "Gesamte CSV herunterladen"
date = "Anfang"
downloadCsv = "Als CSV herunterladen"
energy = "Geladen"
energyGroupedTitle = "Energiemenge {energy}"
energyGroupedTitleByGroup = "Energiemenge {byGroup}"
energySubTitle = "{energy} gesamt"
energyTitle = "{percent} Sonnenenergie"
groupedCo2TitleByGroup = "CO₂-Menge {byGroup}"
groupedPriceTitleByGroup = "Kosten {byGroup}"
historyCo2SubTitle = "{value} gesamt"
historyCo2Title = "⌀ {value} CO₂-Emissionen"
historyPriceSubTitle = "{value} gesamt"
historyPriceTitle = "⌀ {value} Ladepreis"
loadpoint = "Ladepunkt"
noData = "Noch keine Ladevorgänge in diesem Monat."
overview = "Übersicht"

View file

@ -27,6 +27,20 @@ never = "only with enough surplus."
titleAdd = "Add Battery"
titleEdit = "Edit Battery"
[config.charge]
titleAdd = "Add Charge Meter"
titleEdit = "Edit Charge Meter"
[config.charger]
chargers = "EV chargers"
generic = "Generic integrations"
ocppHelp = "Copy this address into your chargers configuration."
ocppLabel = "OCPP-Server URL"
switchsockets = "Switchable sockets"
template = "Manufacturer"
titleAdd = "Add Charger"
titleEdit = "Edit Charger"
[config.circuits]
description = "Ensures, that the sum of all loadpoints connected to a circuit does not exceed the configured power and current limits. Circuits can be nested to build a hierarchy."
title = "Load Management"
@ -62,15 +76,18 @@ energy = "Energy"
feedinPrice = "Feed-in price"
gridPrice = "Grid price"
hemsType = "System"
identifier = "RFID-Identifier"
no = "no"
odometer = "Odometer"
org = "Organization"
phaseCurrents = "Current L1, L2, L3"
phasePowers = "Power L1, L2, L3"
phaseVoltages = "Voltage L1, L2, L3"
phases1p3p = "Phase switch"
power = "Power"
powerRange = "Power"
range = "Range"
singlePhase = "Single phase"
soc = "SoC"
socLimit = "Limit"
temp = "Temperature"
@ -78,6 +95,11 @@ topic = "Topic"
url = "URL"
yes = "yes"
[config.deviceValueChargeStatus]
A = "A (not connected)"
B = "B (connected)"
C = "C (charging)"
[config.eebus]
description = "Configuration that enables evcc to communicate with other EEBus devices."
title = "EEBus"
@ -88,6 +110,7 @@ optional = "optional"
[config.general]
cancel = "Cancel"
delete = "Delete"
docsLink = "See documentation."
experimental = "Experimental"
hideAdvancedSettings = "Hide advanced settings"
@ -100,6 +123,7 @@ save = "Save"
showAdvancedSettings = "Show advanced settings"
telemetry = "Telemetry"
title = "Title"
validateSave = "Validate & save"
[config.grid]
title = "Grid meter"
@ -126,9 +150,84 @@ title = "InfluxDB"
v1Support = "Need support for InfluxDB 1.x?"
v2Support = "Back to InfluxDB 2.x"
[config.loadpoint]
addCharger = "Add charger"
addMeter = "Add dedicated charger meter"
cancel = "Cancel"
chargerLabel = "Charger"
chargerPower11kw = "11 kW"
chargerPower11kwHelp = "Will use a current range of 6 to 16 A."
chargerPower22kw = "22 kW"
chargerPower22kwHelp = "Will use a current range of 6 to 32 A."
chargerPowerCustom = "other"
chargerPowerCustomHelp = "Define a custom current range."
chargerTypeLabel = "Charger type"
chargingTitle = "Charging"
circuitLabel = "Circuit"
circuitUnassigned = "unassigned"
defaultModeHelp = "Charging mode when connecting the vehicle."
defaultModeHelpKeep = "Keeps the last selected charging mode."
defaultModeLabel = "Default mode"
delete = "Delete"
electricalSubtitle = "When in doubt, ask your electrician."
electricalTitle = "Electrical"
energyMeterHelp = "Additional meter if the charger doesn't have an integrated one."
energyMeterLabel = "Energy meter"
estimateLabel = "Interpolate charge level between API updates"
maxCurrentHelp = "Must be greater than minimum current."
maxCurrentLabel = "Maximum current"
minCurrentHelp = "Only go below 6 A if you know what you're doing."
minCurrentLabel = "Minimum current"
noVehicles = "No vehicles are configured."
phases1p = "1-phase"
phases3p = "3-phase"
phasesAutomatic = "Automatic phases"
phasesAutomaticHelp = "Your charger supports automatic switching between 1- and 3-phase charging. In the main screen you can adjust phase behaviour while charging."
phasesHelp = "Number of phases connected to the charger."
phasesLabel = "Phases"
pollIntervalHelp = "Time between vehicle API updates. Short intervals may drain the vehicle battery."
pollIntervalLabel = "Update interval"
pollModeAlways = "always"
pollModeAlwaysHelp = "Always request status updates in regular intervals."
pollModeCharging = "charging"
pollModeChargingHelp = "Only request vehicle status updates when charging."
pollModeConnected = "connected"
pollModeConnectedHelp = "Update vehicle status in regular intervals when connected."
pollModeLabel = "Update behaviour"
priorityHelp = "Higher priority charge points get preferred access to solar surplus."
priorityLabel = "Priority"
save = "Save"
showAllSettings = "Show all settings"
solarBehaviorCustomHelp = "Define your own enable and disable thresholds and delays."
solarBehaviorDefaultHelp = "Only charge with solar surplus. Start after {enableDelay} of surplus. Stop when there is not enough surplus for {disableDelay}."
solarBehaviorLabel = "Solar behaviour"
solarModeCustom = "custom"
solarModeMaximum = "maximum solar"
thresholdDisableDelayLabel = "Disable delay"
thresholdDisableHelpInvalid = "Please use a positive value."
thresholdDisableHelpPositive = "Stop charging, when more than {power} is used from the grid for {delay}."
thresholdDisableHelpZero = "Stop when minimum charge power can't be satisfied for {delay}."
thresholdDisableLabel = "Disable grid power"
thresholdEnableDelayLabel = "Enable delay"
thresholdEnableHelpInvalid = "Please use a negative value."
thresholdEnableHelpNegative = "Start charging, when {surplus} surplus is available for {delay}."
thresholdEnableHelpZero = "Start when minimum charge power surplus is available for {delay}."
thresholdEnableLabel = "Enable grid power"
titleAdd = "Add Charge Point"
titleEdit = "Edit Charge Point"
titleExample = "Garage, Carport, etc."
titleLabel = "Title"
vehicleAutoDetection = "auto detection"
vehicleHelpAutoDetection = "Automatically selects the most plausible vehicle. Manual override is possible."
vehicleHelpDefault = "Always assume this vehicle is charging here. Auto-detection disabled. Manual override is possible."
vehicleLabel = "Default vehicle"
vehiclesTitle = "Vehicles"
[config.main]
addGrid = "Add grid meter"
addLoadpoint = "Add charge point"
addPvBattery = "Add solar or battery"
addTariffs = "Add tariffs"
addVehicle = "Add vehicle"
configured = "configured"
edit = "edit"
@ -194,6 +293,11 @@ little = "little-endian"
http = "HTTP (unencrypted)"
https = "HTTPS (encrypted)"
[config.options.status]
A = "A (not connected)"
B = "B (connected)"
C = "C (charging)"
[config.pv]
titleAdd = "Add Solar Meter"
titleEdit = "Edit Solar Meter"
@ -208,13 +312,16 @@ system = "System"
vehicles = "Vehicles"
[config.sponsor]
addToken = "Enter sponsor token"
changeToken = "Change sponsor token"
addToken = "Enter token"
changeToken = "Change token"
description = "The sponsoring model helps us to maintain the project and sustainably build new and exciting features. As a sponsor you get access to all charger implementations."
descriptionToken = "You get the token from {url}. We also offer a trial token for testing."
error = "The sponsor token is not valid."
labelToken = "Sponsor token"
title = "Sponsorship"
tokenRequired = "You must configure a sponsor token before you can create this device."
tokenRequiredLearnMore = "Learn more."
trialToken = "Trial token"
[config.system]
logs = "Logs"
@ -380,7 +487,9 @@ reset = "Reset password?"
title = "Authentication"
[main]
startConfiguration = "Let's start configuration"
vehicles = "Parking"
welcome = "Hello aboard!"
[main.chargingPlan]
active = "Active"

View file

@ -1,7 +1,9 @@
package server
import (
"encoding/json"
"fmt"
"io"
"math"
"reflect"
"slices"
@ -26,6 +28,13 @@ func parseFloat(payload string) (float64, error) {
return f, err
}
// jsonDecoder returns a json decoder with disallowed unknown fields
func jsonDecoder(r io.Reader) *json.Decoder {
dec := json.NewDecoder(r)
dec.DisallowUnknownFields()
return dec
}
// omitEmpty returns true if struct field is omitempty
func omitEmpty(f reflect.StructField) bool {
tag := f.Tag.Get("json")

View file

@ -156,6 +156,7 @@ func (s *HTTPd) RegisterSiteHandlers(site site.API, valueChan chan<- util.Param)
}
// loadpoint api
// TODO any loadpoint
for id, lp := range site.Loadpoints() {
api := api.PathPrefix(fmt.Sprintf("/loadpoints/%d", id+1)).Subrouter()
@ -236,7 +237,7 @@ func (s *HTTPd) RegisterSystemHandler(valueChan chan<- util.Param, cache *util.P
routes := map[string]route{
"templates": {"GET", "/templates/{class:[a-z]+}", templatesHandler},
"products": {"GET", "/products/{class:[a-z]+}", productsHandler},
"devices": {"GET", "/devices/{class:[a-z]+}", devicesHandler},
"devices": {"GET", "/devices/{class:[a-z]+}", devicesConfigHandler},
"device": {"GET", "/devices/{class:[a-z]+}/{id:[0-9.]+}", deviceConfigHandler},
"devicestatus": {"GET", "/devices/{class:[a-z]+}/{name:[a-zA-Z0-9_.:-]+}/status", deviceStatusHandler},
"dirty": {"GET", "/dirty", getHandler(ConfigDirty)},
@ -247,7 +248,7 @@ func (s *HTTPd) RegisterSystemHandler(valueChan chan<- util.Param, cache *util.P
"testmerged": {"POST", "/test/{class:[a-z]+}/merge/{id:[0-9.]+}", testConfigHandler},
"interval": {"POST", "/interval/{value:[0-9.]+}", settingsSetDurationHandler(keys.Interval)},
"updatesponsortoken": {"POST", "/sponsortoken", updateSponsortokenHandler},
"deletesponsortoken": {"DELETE", "/sponsortoken", settingsDeleteHandler(keys.SponsorToken)},
"deletesponsortoken": {"DELETE", "/sponsortoken", deleteSponsorTokenHandler},
}
// yaml handlers
@ -279,6 +280,17 @@ func (s *HTTPd) RegisterSystemHandler(valueChan chan<- util.Param, cache *util.P
for _, r := range routes {
api.Methods(r.Methods()...).Path(r.Pattern).Handler(r.HandlerFunc)
}
// loadpoints
for _, r := range map[string]route{
"loadpoints": {"GET", "/loadpoints", loadpointsConfigHandler()},
"loadpoint": {"GET", "/loadpoints/{id:[0-9.]+}", loadpointConfigHandler()},
"updateloadpoint": {"PUT", "/loadpoints/{id:[0-9.]+}", updateLoadpointHandler()},
"deleteloadpoint": {"DELETE", "/loadpoints/{id:[0-9.]+}", deleteLoadpointHandler()},
"newloadpoint": {"POST", "/loadpoints", newLoadpointHandler()},
} {
api.Methods(r.Methods()...).Path(r.Pattern).Handler(r.HandlerFunc)
}
}
{ // api/system

View file

@ -4,6 +4,7 @@ import (
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"strconv"
@ -33,11 +34,17 @@ func devicesConfig[T any](class templates.Class, h config.Handler[T]) ([]map[str
return res, nil
}
// devicesHandler returns a device configurations by class
func devicesHandler(w http.ResponseWriter, r *http.Request) {
// devicesConfigHandler returns a device configurations by class
func devicesConfigHandler(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
class, err := templates.ClassString(vars["class"])
// TODO exclude loadpoints here
if err == nil && class == templates.Loadpoint {
err = fmt.Errorf("invalid class %s", class)
}
if err != nil {
jsonError(w, http.StatusBadRequest, err)
return
@ -142,6 +149,8 @@ func deviceConfigHandler(w http.ResponseWriter, r *http.Request) {
return
}
// TODO return application/yaml content type if type != template
jsonResult(w, res)
}
@ -202,7 +211,7 @@ func newDevice[T any](class templates.Class, req map[string]any, newFromConf new
return nil, err
}
return &conf, h.Add(config.NewConfigurableDevice[T](conf, instance))
return &conf, h.Add(config.NewConfigurableDevice[T](&conf, instance))
}
// newDeviceHandler creates a new device by class
@ -215,6 +224,8 @@ func newDeviceHandler(w http.ResponseWriter, r *http.Request) {
return
}
// TODO add application/yaml content type, reject type==template
var req map[string]any
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
jsonError(w, http.StatusBadRequest, err)
@ -288,6 +299,8 @@ func updateDeviceHandler(w http.ResponseWriter, r *http.Request) {
return
}
// TODO add application/yaml content type, reject type==template
var req map[string]any
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
jsonError(w, http.StatusBadRequest, err)

View file

@ -188,6 +188,14 @@ func testInstance(instance any) map[string]testResult {
res["chargedEnergy"] = makeResult(val, err)
}
if _, ok := instance.(api.PhaseSwitcher); ok {
res["phases1p3p"] = makeResult(true, nil)
}
if cc, ok := instance.(api.PhaseDescriber); ok {
res["singlePhase"] = makeResult(cc.Phases() == 1, nil)
}
if dev, ok := instance.(api.VehicleRange); ok {
val, err := dev.Range()
res["range"] = makeResult(val, err)
@ -198,5 +206,10 @@ func testInstance(instance any) map[string]testResult {
res["socLimit"] = makeResult(val, err)
}
if dev, ok := instance.(api.Identifier); ok {
val, err := dev.Identify()
res["identifier"] = makeResult(val, err)
}
return res
}

View file

@ -0,0 +1,251 @@
package server
import (
"errors"
"io"
"net/http"
"strconv"
"github.com/evcc-io/evcc/core"
"github.com/evcc-io/evcc/core/loadpoint"
coresettings "github.com/evcc-io/evcc/core/settings"
"github.com/evcc-io/evcc/util"
"github.com/evcc-io/evcc/util/config"
"github.com/evcc-io/evcc/util/templates"
"github.com/gorilla/mux"
"github.com/samber/lo"
)
func getLoadpointStaticConfig(lp loadpoint.API) loadpoint.StaticConfig {
return loadpoint.StaticConfig{
Charger: lp.GetChargerName(),
Meter: lp.GetMeterName(),
Circuit: lp.GetCircuitName(),
Vehicle: lp.GetDefaultVehicle(),
}
}
func getLoadpointDynamicConfig(lp loadpoint.API) loadpoint.DynamicConfig {
planTime, planEnergy := lp.GetPlanEnergy()
return loadpoint.DynamicConfig{
Title: lp.GetTitle(),
DefaultMode: string(lp.GetDefaultMode()),
Priority: lp.GetPriority(),
Phases: lp.GetPhases(),
MinCurrent: lp.GetMinCurrent(),
MaxCurrent: lp.GetMaxCurrent(),
SmartCostLimit: lp.GetSmartCostLimit(),
Thresholds: lp.GetThresholds(),
Soc: lp.GetSocConfig(),
PlanEnergy: planEnergy,
PlanTime: planTime,
LimitEnergy: lp.GetLimitEnergy(),
LimitSoc: lp.GetLimitSoc(),
}
}
type loadpointFullConfig struct {
ID int `json:"id,omitempty"` // db row id
Name string `json:"name"` // either slice index (yaml) or db:<row id>
// static config
loadpoint.StaticConfig
loadpoint.DynamicConfig
}
func loadpointSplitConfig(r io.Reader) (loadpoint.DynamicConfig, map[string]any, error) {
var payload map[string]any
if err := jsonDecoder(r).Decode(&payload); err != nil {
return loadpoint.DynamicConfig{}, nil, err
}
return loadpoint.SplitConfig(payload)
}
// loadpointConfig returns a single loadpoint's configuration
func loadpointConfig(dev config.Device[loadpoint.API]) loadpointFullConfig {
lp := dev.Instance()
var id int
if configurable, ok := dev.(config.ConfigurableDevice[loadpoint.API]); ok {
id = configurable.ID()
}
res := loadpointFullConfig{
ID: id,
Name: dev.Config().Name,
StaticConfig: getLoadpointStaticConfig(lp),
DynamicConfig: getLoadpointDynamicConfig(lp),
}
return res
}
// loadpointsConfigHandler returns a device configurations by class
func loadpointsConfigHandler() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
res := lo.Map(config.Loadpoints().Devices(), func(dev config.Device[loadpoint.API], _ int) loadpointFullConfig {
return loadpointConfig(dev)
})
jsonResult(w, res)
}
}
// loadpointConfigHandler returns a device configurations by class
func loadpointConfigHandler() http.HandlerFunc {
h := config.Loadpoints()
return func(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
id, err := strconv.Atoi(vars["id"])
if err != nil {
jsonError(w, http.StatusBadRequest, err)
return
}
dev, err := h.ByName(config.NameForID(id))
if err != nil {
jsonError(w, http.StatusBadRequest, err)
return
}
res := loadpointConfig(dev)
jsonResult(w, res)
}
}
// newLoadpointHandler creates a new loadpoint
func newLoadpointHandler() http.HandlerFunc {
h := config.Loadpoints()
// TODO revert charger, meter etc
return func(w http.ResponseWriter, r *http.Request) {
dynamic, static, err := loadpointSplitConfig(r.Body)
if err != nil {
jsonError(w, http.StatusBadRequest, err)
return
}
id := len(h.Devices())
name := "lp-" + strconv.Itoa(id+1)
log := util.NewLoggerWithLoadpoint(name, id+1)
conf, err := config.AddConfig(templates.Loadpoint, "", static)
if err != nil {
jsonError(w, http.StatusBadRequest, err)
return
}
settings := coresettings.NewConfigSettingsAdapter(log, &conf)
instance, err := core.NewLoadpointFromConfig(log, settings, static)
if err != nil {
conf.Delete()
jsonError(w, http.StatusBadRequest, err)
return
}
dev := config.NewConfigurableDevice[loadpoint.API](&conf, instance)
if err := dynamic.Apply(instance); err != nil {
conf.Delete()
jsonError(w, http.StatusBadRequest, err)
return
}
if err := h.Add(dev); err != nil {
conf.Delete()
jsonError(w, http.StatusBadRequest, err)
return
}
setConfigDirty()
w.WriteHeader(http.StatusOK)
}
}
// updateLoadpointHandler returns a device configurations by class
func updateLoadpointHandler() http.HandlerFunc {
h := config.Loadpoints()
return func(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
id, err := strconv.Atoi(vars["id"])
if err != nil {
jsonError(w, http.StatusBadRequest, err)
return
}
dev, err := h.ByName(config.NameForID(id))
if err != nil {
jsonError(w, http.StatusBadRequest, err)
return
}
configurable, ok := dev.(config.ConfigurableDevice[loadpoint.API])
if !ok {
jsonError(w, http.StatusBadRequest, errors.New("not configurable"))
return
}
instance := dev.Instance()
dynamic, static, err := loadpointSplitConfig(r.Body)
if err != nil {
jsonError(w, http.StatusBadRequest, err)
return
}
// static
if err := configurable.PartialUpdate(static, instance); err != nil {
jsonError(w, http.StatusBadRequest, err)
return
}
// dynamic
if err := dynamic.Apply(instance); err != nil {
jsonError(w, http.StatusBadRequest, err)
return
}
setConfigDirty()
w.WriteHeader(http.StatusOK)
}
}
// deleteLoadpointHandler deletes a loadpoint
func deleteLoadpointHandler() http.HandlerFunc {
h := config.Loadpoints()
return func(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
id, err := strconv.Atoi(vars["id"])
if err != nil {
jsonError(w, http.StatusBadRequest, err)
return
}
setConfigDirty()
if err := deleteDevice(id, h); err != nil {
jsonError(w, http.StatusBadRequest, err)
return
}
res := struct {
ID int `json:"id"`
}{
ID: id,
}
jsonResult(w, res)
}
}

View file

@ -1,7 +1,6 @@
package server
import (
"encoding/json"
"net/http"
"github.com/evcc-io/evcc/core/site"
@ -47,7 +46,7 @@ func updateSiteHandler(site site.API) http.HandlerFunc {
Battery *[]string
}
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
if err := jsonDecoder(r.Body).Decode(&payload); err != nil {
jsonError(w, http.StatusBadRequest, err)
return
}

View file

@ -32,3 +32,9 @@ func updateSponsortokenHandler(w http.ResponseWriter, r *http.Request) {
jsonResult(w, sponsor.Status())
}
func deleteSponsorTokenHandler(w http.ResponseWriter, r *http.Request) {
settings.SetString(keys.SponsorToken, "")
setConfigDirty()
jsonResult(w, true)
}

View file

@ -165,7 +165,7 @@ func (m *Influx) Run(site site.API, in <-chan util.Param) {
if param.Loadpoint != nil {
lp := site.Loadpoints()[*param.Loadpoint]
tags["loadpoint"] = lp.Title()
tags["loadpoint"] = lp.GetTitle()
if v := lp.GetVehicle(); v != nil {
tags["vehicle"] = v.Title()
}

View file

@ -203,6 +203,7 @@ func (m *MQTT) listenLoadpointSetters(topic string, site site.API, lp loadpoint.
{"mode", setterFunc(api.ChargeModeString, pass(lp.SetMode))},
{"phases", intSetter(lp.SetPhases)},
{"limitSoc", intSetter(pass(lp.SetLimitSoc))},
{"priority", intSetter(pass(lp.SetPriority))},
{"minCurrent", floatSetter(lp.SetMinCurrent)},
{"maxCurrent", floatSetter(lp.SetMaxCurrent)},
{"limitEnergy", floatSetter(pass(lp.SetLimitEnergy))},

View file

@ -14,7 +14,7 @@ params:
de: Ladezustand
en: Charge status
type: choice
choice: [A, B, C, D, E, F]
choice: [A, B, C]
default: A
- name: power
description:
@ -36,6 +36,13 @@ params:
example: 16
type: int
advanced: true
- name: phases1p3p
description:
de: Phasenumschaltung
en: Phase switching
type: bool
default: false
advanced: true
render: |
type: custom
@ -55,3 +62,10 @@ render: |
power:
source: const
value: {{ .power }}
{{ if eq .phases1p3p "true" }}
phases1p3p:
source: js
script: |
3
tos: true
{{ end }}

View file

@ -41,13 +41,16 @@ test.describe("battery meter", async () => {
await expect(meterModal.getByTestId("device-tag-soc")).toContainText("75.0%");
await expect(meterModal.getByTestId("device-tag-power")).toContainText("-2.5 kW");
await meterModal.getByRole("button", { name: "Save" }).click();
await expect(meterModal).not.toBeVisible();
await expect(page.getByTestId("battery")).toBeVisible(1);
await expect(page.getByTestId("battery")).toContainText("openems");
// edit #1
await page.getByTestId("battery").getByRole("button", { name: "edit" }).click();
await expect(meterModal).toBeVisible();
await meterModal.getByLabel("Battery capacity in kWh").fill("20");
await meterModal.getByRole("button", { name: "Validate & save" }).click();
await expect(meterModal).not.toBeVisible();
const battery = page.getByTestId("battery");
await expect(battery).toBeVisible(1);

View file

@ -1,24 +0,0 @@
site:
title: Hello World
loadpoints:
- title: Carport
charger: charger
chargers:
- name: charger
type: custom
enable:
source: js
script:
enabled:
source: js
script: |
false
status:
source: js
script: |
"B"
maxcurrent:
source: js
script:

View file

@ -3,13 +3,13 @@ import { start, stop, restart, baseUrl } from "./evcc";
import { startSimulator, stopSimulator, simulatorUrl, simulatorHost } from "./simulator";
import { enableExperimental } from "./utils";
const CONFIG_EMPTY = "config-empty.evcc.yaml";
const CONFIG_ONE_LP = "config-one-lp.evcc.yaml";
test.use({ baseURL: baseUrl() });
test.beforeAll(async () => {
await startSimulator();
await start(CONFIG_EMPTY);
await start(CONFIG_ONE_LP);
});
test.afterAll(async () => {
await stop();
@ -34,11 +34,11 @@ test.describe("grid meter", async () => {
await page.goto("/#/config");
await enableExperimental(page);
await expect(page.getByTestId("grid")).toHaveCount(1);
await expect(page.getByTestId("grid").getByTestId("device-tag-configured")).toContainText("no");
await expect(page.getByTestId("grid")).toHaveCount(0);
await expect(page.getByTestId("add-grid")).toBeVisible();
// create #1
await page.getByTestId("grid").getByRole("button", { name: "edit" }).click();
await page.getByTestId("add-grid").click();
const meterModal = page.getByTestId("meter-modal");
await meterModal.getByLabel("Manufacturer").selectOption("OpenEMS");
@ -50,7 +50,7 @@ test.describe("grid meter", async () => {
await expect(meterModal).not.toBeVisible();
// restart
await restart(CONFIG_EMPTY);
await restart(CONFIG_ONE_LP);
await expect(page.getByTestId("grid").getByTestId("device-tag-power")).toContainText("5.0 kW");
// check in main ui
@ -64,7 +64,7 @@ test.describe("grid meter", async () => {
await meterModal.getByRole("button", { name: "Delete" }).click();
await expect(meterModal).not.toBeVisible();
await expect(page.getByTestId("grid")).toHaveCount(1);
await expect(page.getByTestId("grid").getByTestId("device-tag-configured")).toContainText("no");
await expect(page.getByTestId("grid")).not.toBeVisible();
await expect(page.getByTestId("add-grid")).toBeVisible();
});
});

View file

@ -0,0 +1,277 @@
import { test, expect } from "@playwright/test";
import { start, stop, restart, baseUrl } from "./evcc";
const CONFIG_EMPTY = "config-empty.evcc.yaml";
const CONFIG_ONE_LP = "config-one-lp.evcc.yaml";
test.use({ baseURL: baseUrl() });
test.afterEach(async () => {
await stop();
});
async function enableExperimental(page) {
await page
.getByTestId("generalconfig-experimental")
.getByRole("button", { name: "edit" })
.click();
await page.getByLabel("Experimental 🧪").click();
await page.getByRole("button", { name: "Close" }).click();
}
async function addDemoCharger(page) {
const lpModal = page.getByTestId("loadpoint-modal");
await lpModal.getByRole("button", { name: "Add charger" }).click();
const modal = page.getByTestId("charger-modal");
await modal.getByLabel("Manufacturer").selectOption("Demo charger");
await modal.getByRole("button", { name: "Save" }).click();
await expect(modal).not.toBeVisible();
}
async function addVehicle(page, title) {
await page.getByRole("button", { name: "Add vehicle" }).click();
const modal = page.getByTestId("vehicle-modal");
await modal.getByLabel("Manufacturer").selectOption("Generic vehicle");
await modal.getByLabel("Title").fill(title);
await modal.getByRole("button", { name: "Validate & save" }).click();
await expect(modal).not.toBeVisible();
}
async function newLoadpoint(page, title) {
const lpModal = page.getByTestId("loadpoint-modal");
await page.getByRole("button", { name: "Add charge point" }).click();
await expect(lpModal).toBeVisible();
await lpModal.getByLabel("Title").fill(title);
}
test.describe("loadpoint", async () => {
test("create, update and delete", async ({ page }) => {
await start(CONFIG_EMPTY);
await page.goto("/#/config");
await enableExperimental(page);
const lpModal = page.getByTestId("loadpoint-modal");
const chargerModal = page.getByTestId("charger-modal");
await expect(page.getByTestId("loadpoint")).toHaveCount(0);
// new loadpoint
await newLoadpoint(page, "Solar Carport");
await lpModal.getByRole("button", { name: "Add charger" }).click();
// add charger
await chargerModal.getByLabel("Manufacturer").selectOption("Demo charger");
await chargerModal.getByLabel("Charge status").selectOption("C");
await chargerModal.getByLabel("Power").fill("11000");
await chargerModal.getByRole("button", { name: "Save" }).click();
await expect(lpModal.getByLabel("Title")).toHaveValue("Solar Carport");
// create loadpoint
await lpModal.getByRole("button", { name: "Save" }).click();
await expect(page.getByTestId("loadpoint")).toHaveCount(1);
await expect(page.getByTestId("loadpoint")).toContainText("Solar Carport");
await expect(page.getByTestId("loadpoint")).toContainText("charging");
await expect(page.getByTestId("loadpoint")).toContainText("11.0 kW");
// restart button appears
const restartButton = await page
.getByTestId("bottom-banner")
.getByRole("button", { name: "Restart" });
await expect(restartButton).toBeVisible();
// restart
await restart(CONFIG_EMPTY);
await page.reload();
await expect(page.getByTestId("loadpoint")).toHaveCount(1);
await expect(page.getByTestId("loadpoint")).toContainText("Solar Carport");
// update loadpoint title
await page.getByTestId("loadpoint").getByRole("button", { name: "edit" }).click();
await lpModal.getByLabel("Title").fill("Solar Carport 2");
await lpModal.getByRole("button", { name: "Save" }).click();
await expect(page.getByTestId("loadpoint")).toContainText("Solar Carport 2");
// restart
await restart(CONFIG_EMPTY);
await page.reload();
await expect(page.getByTestId("loadpoint")).toHaveCount(1);
await expect(page.getByTestId("loadpoint")).toContainText("Solar Carport 2");
// update loadpoint power
await page.getByTestId("loadpoint").getByRole("button", { name: "edit" }).click();
await lpModal.getByTestId("chargerPower-22kw").click();
await lpModal.getByRole("button", { name: "Save" }).click();
await expect(lpModal).not.toBeVisible();
// restart
await restart(CONFIG_EMPTY);
await page.reload();
await expect(page.getByTestId("loadpoint")).toHaveCount(1);
await page.getByTestId("loadpoint").getByRole("button", { name: "edit" }).click();
await expect(lpModal.getByTestId("chargerPower-22kw")).toHaveClass(/active/);
await expect(lpModal.getByLabel("Title")).toHaveValue("Solar Carport 2");
await lpModal.getByRole("button", { name: "Close" }).click();
// delete loadpoint
await page.getByTestId("loadpoint").getByRole("button", { name: "edit" }).click();
await lpModal.getByRole("button", { name: "Delete" }).click();
await expect(page.getByTestId("loadpoint")).toHaveCount(0);
// restart
await restart(CONFIG_EMPTY);
await page.reload();
await expect(page.getByTestId("loadpoint")).toHaveCount(0);
});
test("priority", async ({ page }) => {
await start(CONFIG_ONE_LP);
await page.goto("/#/config");
await enableExperimental(page);
const lpModal = page.getByTestId("loadpoint-modal");
// loadpoint from yaml
await expect(page.getByTestId("loadpoint")).toHaveCount(1);
await expect(page.getByTestId("loadpoint")).toContainText("Carport");
// add two more loadpoints
for (const title of ["Garage", "Garden"]) {
await newLoadpoint(page, title);
await addDemoCharger(page);
await lpModal.getByRole("button", { name: "Save" }).click();
await expect(lpModal).not.toBeVisible();
}
// three loadpoints
await expect(page.getByTestId("loadpoint")).toHaveCount(3);
await expect(page.getByTestId("loadpoint").nth(0)).toContainText("Carport");
await expect(page.getByTestId("loadpoint").nth(1)).toContainText("Garage");
await expect(page.getByTestId("loadpoint").nth(2)).toContainText("Garden");
// second loadpoint > priority 2
await page.getByTestId("loadpoint").nth(1).getByRole("button", { name: "edit" }).click();
await lpModal.getByTestId("loadpointParamPriority-2").click();
await lpModal.getByRole("button", { name: "Save" }).click();
await expect(lpModal).not.toBeVisible();
// third loadpoint > priority 1
await page.getByTestId("loadpoint").nth(2).getByRole("button", { name: "edit" }).click();
await lpModal.getByTestId("loadpointParamPriority-1").click();
await lpModal.getByRole("button", { name: "Save" }).click();
await expect(lpModal).not.toBeVisible();
// restart
await restart(CONFIG_ONE_LP);
await page.reload();
// check priorities
await page.getByTestId("loadpoint").nth(1).getByRole("button", { name: "edit" }).click();
await expect(lpModal.getByTestId("loadpointParamPriority-2")).toHaveClass(/active/);
await lpModal.getByRole("button", { name: "Close" }).click();
await page.getByTestId("loadpoint").nth(2).getByRole("button", { name: "edit" }).click();
await expect(lpModal.getByTestId("loadpointParamPriority-1")).toHaveClass(/active/);
await lpModal.getByRole("button", { name: "Close" }).click();
});
test("vehicle", async ({ page }) => {
await start(CONFIG_EMPTY);
await page.goto("/#/config");
await enableExperimental(page);
const LP_1 = "Carport";
const LP_2 = "Garage";
const VEHICLE_1 = "Green Car";
const VEHICLE_2 = "Yellow Van";
const lpModal = page.getByTestId("loadpoint-modal");
// add loadpoint > no vehicle option
await newLoadpoint(page, LP_1);
await addDemoCharger(page);
await expect(lpModal.getByLabel("Default vehicle")).not.toBeVisible();
await lpModal.getByRole("button", { name: "Save" }).click();
await expect(lpModal).not.toBeVisible();
// edit loadpoint
await page.getByTestId("loadpoint").nth(0).getByRole("button", { name: "edit" }).click();
await expect(lpModal).toContainText("No vehicles are configured.");
await lpModal.getByRole("button", { name: "Close" }).click();
// add vehicles
for (const title of [VEHICLE_1, VEHICLE_2]) {
await addVehicle(page, title);
}
// set vehicle as default for loadpoint 1
await page.getByTestId("loadpoint").nth(0).getByRole("button", { name: "edit" }).click();
await expect(lpModal).toBeVisible();
await lpModal.getByLabel("Default vehicle").selectOption(VEHICLE_1);
await lpModal.getByRole("button", { name: "Save" }).click();
await expect(lpModal).not.toBeVisible();
// add second loadpoint
await newLoadpoint(page, LP_2);
await addDemoCharger(page);
await lpModal.getByLabel("Default vehicle").selectOption(VEHICLE_2);
await lpModal.getByRole("button", { name: "Save" }).click();
// restart
await restart(CONFIG_EMPTY);
await page.reload();
// check loadpoint default vehicles
for (const [index, vehicle] of [VEHICLE_1, VEHICLE_2].entries()) {
await page.getByTestId("loadpoint").nth(index).getByRole("button", { name: "edit" }).click();
await expect(lpModal.locator("#loadpointParamVehicle option:checked")).toHaveText(vehicle);
await lpModal.getByRole("button", { name: "Close" }).click();
await expect(lpModal).not.toBeVisible();
}
});
test("keep mode", async ({ page }) => {
await start(CONFIG_EMPTY);
await page.goto("/#/config");
await enableExperimental(page);
// add grid meter
await page.getByRole("button", { name: "Add grid meter" }).click();
const meterModal = page.getByTestId("meter-modal");
await meterModal.getByLabel("Manufacturer").selectOption("Demo meter");
await meterModal.getByLabel("Power").fill("-1000");
await meterModal.getByRole("button", { name: "Save" }).click();
await expect(meterModal).not.toBeVisible();
// add a loadpoint with dummy charger,
const lpModal = page.getByTestId("loadpoint-modal");
await newLoadpoint(page, "Carport");
await addDemoCharger(page);
await lpModal.getByLabel("Default mode").selectOption("---");
await lpModal.getByRole("button", { name: "Save" }).click();
await expect(lpModal).not.toBeVisible();
await restart(CONFIG_EMPTY);
// change on main ui
await page.goto("/");
await expect(page.getByRole("button", { name: "Off" })).toHaveClass(/active/);
await page.getByRole("button", { name: "Solar", exact: true }).click();
await restart(CONFIG_EMPTY);
await page.reload();
await expect(page.getByRole("button", { name: "Solar", exact: true })).toHaveClass(/active/);
// change default mode in config to fast
await page.goto("/#/config");
// open first loadpoint
await page.getByTestId("loadpoint").getByRole("button", { name: "edit" }).click();
await expect(lpModal).toBeVisible();
await lpModal.getByLabel("Default mode").selectOption("Fast");
await lpModal.getByRole("button", { name: "Save" }).click();
await expect(lpModal).not.toBeVisible();
await restart(CONFIG_EMPTY);
// check loadpoint mode
await page.goto("/");
await expect(page.getByRole("button", { name: "Fast" })).toHaveClass(/active/);
});
});

View file

@ -0,0 +1,101 @@
import { test, expect } from "@playwright/test";
import { start, stop, restart, baseUrl } from "./evcc";
import { enableExperimental } from "./utils";
const CONFIG = "config-empty.evcc.yaml";
test.use({ baseURL: baseUrl() });
test.beforeAll(async () => {
await start(CONFIG, null, "");
});
test.afterAll(async () => {
await stop();
});
const PASSWORD = "secret";
test.describe("onboarding", async () => {
test("password, loadpoint, grid, pv", async ({ page }) => {
await page.goto("/");
// set admin password
const admin = page.getByTestId("password-modal");
await expect(admin).toBeVisible();
await admin.getByLabel("New password").fill(PASSWORD);
await admin.getByLabel("Repeat password").fill(PASSWORD);
await admin.getByRole("button", { name: "Create Password" }).click();
await expect(admin).not.toBeVisible();
// onboarding
await expect(page.locator("body")).toContainText("Hello aboard!");
await page.getByRole("link", { name: "Let's start configuration" }).click();
// login
const login = page.getByTestId("login-modal");
await expect(login).toBeVisible();
await login.getByLabel("Password").fill(PASSWORD);
await login.getByRole("button", { name: "Login" }).click();
await expect(login).not.toBeVisible();
// config page
await expect(page.getByRole("heading", { name: "Configuration" })).toBeVisible();
await enableExperimental(page);
// create loadpoint with charger
await page.getByTestId("add-loadpoint").click();
const lpModal = page.getByTestId("loadpoint-modal");
await lpModal.getByLabel("Title").fill("Solar Carport");
await lpModal.getByRole("button", { name: "Add charger" }).click();
const chargerModal = page.getByTestId("charger-modal");
await chargerModal.getByLabel("Manufacturer").selectOption("Demo charger");
await chargerModal.getByLabel("Charge status").selectOption("C");
await chargerModal.getByLabel("Power").fill("3000");
await chargerModal.getByRole("button", { name: "Save" }).click();
await expect(chargerModal).not.toBeVisible();
await lpModal.getByRole("button", { name: "Save" }).click();
await expect(lpModal).not.toBeVisible();
// create grid meter
await page.getByRole("button", { name: "Add grid meter" }).click();
const gridModal = page.getByTestId("meter-modal");
await expect(gridModal).toBeVisible();
await gridModal.getByLabel("Manufacturer").selectOption("Demo meter");
await gridModal.getByLabel("Power").fill("-2000");
await gridModal.getByRole("button", { name: "Save" }).click();
await expect(gridModal).not.toBeVisible();
// create pv meter
await page.getByRole("button", { name: "Add solar or battery" }).click();
const pvModal = page.getByTestId("meter-modal");
await expect(gridModal).toBeVisible();
await pvModal.getByRole("button", { name: "Add solar meter" }).click();
await pvModal.getByLabel("Manufacturer").selectOption("Demo meter");
await pvModal.getByLabel("Power").fill("5000");
await pvModal.getByRole("button", { name: "Save" }).click();
await expect(pvModal).not.toBeVisible();
// restart
const restartButton = await page
.getByTestId("bottom-banner")
.getByRole("button", { name: "Restart" });
await expect(restartButton).toBeVisible();
await restart(CONFIG);
await expect(restartButton).not.toBeVisible();
// navigate to main screen
await page.getByTestId("home-link").click();
// verify configuration
await page.getByTestId("visualization").click();
await expect(page.getByTestId("energyflow-entry-production")).toContainText("5.0 kW");
await expect(page.getByTestId("energyflow-entry-loadpoints")).toContainText("3.0 kW");
await expect(page.getByTestId("energyflow-entry-gridexport")).toContainText("2.0 kW");
const loadpoint = page.getByTestId("loadpoint");
await expect(loadpoint.getByRole("heading", { name: "Solar Carport" })).toBeVisible();
await expect(loadpoint).toContainText("3.0 kW");
await expect(loadpoint).toContainText("Charging…");
});
});

24
tests/config-one-lp.evcc.yaml Executable file
View file

@ -0,0 +1,24 @@
site:
title: Hello World
loadpoints:
- title: Carport
charger: charger
chargers:
- name: charger
type: custom
enable:
source: js
script:
enabled:
source: js
script: |
false
status:
source: js
script: |
"B"
maxcurrent:
source: js
script:

View file

@ -24,17 +24,15 @@ test.describe("tariffs", async () => {
await start(CONFIG_GRID_ONLY);
await goToConfig(page);
await expect(page.getByTestId("tariffs")).toBeVisible();
await expect(page.getByTestId("tariffs")).toContainText(
["Tariffs", "Currency", "EUR"].join("")
);
await expect(page.getByTestId("tariffs")).not.toBeVisible();
await expect(page.getByTestId("add-tariffs")).toBeVisible();
});
test("tariffs via ui", async ({ page }) => {
await start(CONFIG_GRID_ONLY);
await goToConfig(page);
await page.getByTestId("tariffs").getByRole("button", { name: "edit" }).click();
await page.getByTestId("add-tariffs").click();
const modal = await page.getByTestId("tariffs-modal");
await expect(modal).toBeVisible();
await page.waitForLoadState("networkidle");

View file

@ -42,6 +42,30 @@ const openemsMiddleware = (req, res, next) => {
}
};
const teslaloggerMiddleware = (req, res, next) => {
if (req.method === "GET" && req.originalUrl.startsWith("/currentjson/")) {
const id = parseInt(req.originalUrl.split("/")[2]);
const vehicle = state.vehicles[id - 1];
if (!vehicle) {
res.statusCode = 404;
res.end(JSON.stringify({ error: "Vehicle not found" }));
return;
}
const data = {
battery_level: vehicle.soc,
battery_range_km: vehicle.range,
plugged_in: true,
charging: false,
odometer: 10000,
is_preconditioning: false,
charge_current_request: 10,
};
res.end(JSON.stringify(data));
} else {
next();
}
};
export default () => ({
name: "api",
enforce: "pre",
@ -51,6 +75,7 @@ export default () => ({
server.middlewares.use(bodyParser.json());
server.middlewares.use(stateApiMiddleware);
server.middlewares.use(openemsMiddleware);
server.middlewares.use(teslaloggerMiddleware);
};
},
});

View file

@ -4,6 +4,7 @@ import (
"encoding/json"
"fmt"
"dario.cat/mergo"
"github.com/evcc-io/evcc/util/templates"
"gorm.io/gorm"
)
@ -74,6 +75,29 @@ func (d *Config) Update(conf map[string]any) error {
})
}
// PartialUpdate partially updates a config's details to the database
func (d *Config) PartialUpdate(conf map[string]any) error {
return db.Transaction(func(tx *gorm.DB) error {
var config Config
if err := tx.Where(Config{Class: d.Class, ID: d.ID}).First(&config).Error; err != nil {
return err
}
actual := d.detailsAsMap()
if err := mergo.Merge(&actual, conf, mergo.WithOverride); err != nil {
return err
}
val, err := detailsFromMap(actual)
if err != nil {
return err
}
d.Value = val
return tx.Save(&d).Error
})
}
// Delete deletes a config from the database
func (d *Config) Delete() error {
return db.Delete(Config{ID: d.ID}).Error

View file

@ -7,16 +7,19 @@ type Device[T any] interface {
type ConfigurableDevice[T any] interface {
Device[T]
ID() int
// Assign(T)
// Update1(map[string]any) error
Update(map[string]any, T) error
PartialUpdate(map[string]any, T) error
Delete() error
}
type configurableDevice[T any] struct {
config Config
config *Config
instance T
}
func NewConfigurableDevice[T any](config Config, instance T) ConfigurableDevice[T] {
func NewConfigurableDevice[T any](config *Config, instance T) ConfigurableDevice[T] {
return &configurableDevice[T]{
config: config,
instance: instance,
@ -35,6 +38,14 @@ func (d *configurableDevice[T]) ID() int {
return d.config.ID
}
// func (d *configurableDevice[T]) Assign(instance T) {
// d.instance = instance
// }
// func (d *configurableDevice[T]) Update(config map[string]any) error {
// return d.config.Update(config)
// }
func (d *configurableDevice[T]) Update(config map[string]any, instance T) error {
if err := d.config.Update(config); err != nil {
return err
@ -43,6 +54,14 @@ func (d *configurableDevice[T]) Update(config map[string]any, instance T) error
return nil
}
func (d *configurableDevice[T]) PartialUpdate(config map[string]any, instance T) error {
if err := d.config.PartialUpdate(config); err != nil {
return err
}
d.instance = instance
return nil
}
func (d *configurableDevice[T]) Delete() error {
return d.config.Delete()
}

View file

@ -3,6 +3,7 @@ package config
import (
evbus "github.com/asaskevich/EventBus"
"github.com/evcc-io/evcc/api"
"github.com/evcc-io/evcc/core/loadpoint"
)
var bus = evbus.New()
@ -12,6 +13,7 @@ var instance struct {
chargers *handler[api.Charger]
vehicles *handler[api.Vehicle]
circuits *handler[api.Circuit]
loadpoints *handler[loadpoint.API]
}
func init() {
@ -23,6 +25,7 @@ func Reset() {
instance.chargers = &handler[api.Charger]{topic: "charger"}
instance.vehicles = &handler[api.Vehicle]{topic: "vehicle"}
instance.circuits = &handler[api.Circuit]{topic: "circuit"}
instance.loadpoints = &handler[loadpoint.API]{topic: "loadpoint"}
}
type Handler[T any] interface {
@ -49,6 +52,10 @@ func Circuits() Handler[api.Circuit] {
return instance.circuits
}
func Loadpoints() Handler[loadpoint.API] {
return instance.loadpoints
}
// Instances returns the instances of the given devices
func Instances[T any](devices []Device[T]) []T {
res := make([]T, 0, len(devices))

View file

@ -9,5 +9,6 @@ const (
Meter
Vehicle
Tariff
Loadpoint
Circuit
)

View file

@ -7,11 +7,11 @@ import (
"strings"
)
const _ClassName = "chargermetervehicletariffcircuit"
const _ClassName = "chargermetervehicletariffloadpointcircuit"
var _ClassIndex = [...]uint8{0, 7, 12, 19, 25, 32}
var _ClassIndex = [...]uint8{0, 7, 12, 19, 25, 34, 41}
const _ClassLowerName = "chargermetervehicletariffcircuit"
const _ClassLowerName = "chargermetervehicletariffloadpointcircuit"
func (i Class) String() string {
i -= 1
@ -29,10 +29,11 @@ func _ClassNoOp() {
_ = x[Meter-(2)]
_ = x[Vehicle-(3)]
_ = x[Tariff-(4)]
_ = x[Circuit-(5)]
_ = x[Loadpoint-(5)]
_ = x[Circuit-(6)]
}
var _ClassValues = []Class{Charger, Meter, Vehicle, Tariff, Circuit}
var _ClassValues = []Class{Charger, Meter, Vehicle, Tariff, Loadpoint, Circuit}
var _ClassNameToValueMap = map[string]Class{
_ClassName[0:7]: Charger,
@ -43,8 +44,10 @@ var _ClassNameToValueMap = map[string]Class{
_ClassLowerName[12:19]: Vehicle,
_ClassName[19:25]: Tariff,
_ClassLowerName[19:25]: Tariff,
_ClassName[25:32]: Circuit,
_ClassLowerName[25:32]: Circuit,
_ClassName[25:34]: Loadpoint,
_ClassLowerName[25:34]: Loadpoint,
_ClassName[34:41]: Circuit,
_ClassLowerName[34:41]: Circuit,
}
var _ClassNames = []string{
@ -52,7 +55,8 @@ var _ClassNames = []string{
_ClassName[7:12],
_ClassName[12:19],
_ClassName[19:25],
_ClassName[25:32],
_ClassName[25:34],
_ClassName[34:41],
}
// ClassString retrieves an enum value from the enum constants string name.

View file

@ -40,6 +40,8 @@ func (v *embed) Capacity() float64 {
return v.Capacity_
}
var _ api.PhaseDescriber = (*embed)(nil)
// Phases returns the phases used by the vehicle
func (v *embed) Phases() int {
return v.Phases_