Config UI: add loadpoints (BC) (#18137)
This commit is contained in:
parent
d7fcdf663f
commit
864c2cf599
92 changed files with 4643 additions and 1116 deletions
10
.github/workflows/default.yml
vendored
10
.github/workflows/default.yml
vendored
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
},
|
||||
|
|
|
|||
428
assets/js/components/Config/ChargerModal.vue
Normal file
428
assets/js/components/Config/ChargerModal.vue
Normal 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>
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
844
assets/js/components/Config/LoadpointModal.vue
Normal file
844
assets/js/components/Config/LoadpointModal.vue
Normal 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>
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
21
assets/js/components/Config/SponsorTokenRequired.vue
Normal file
21
assets/js/components/Config/SponsorTokenRequired.vue
Normal 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>
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -138,8 +138,4 @@ export default {
|
|||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.btn-cancel {
|
||||
margin-left: -0.75rem;
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -184,7 +184,7 @@ export default {
|
|||
phasesConfigured: Number,
|
||||
phasesActive: Number,
|
||||
chargerPhases1p3p: Boolean,
|
||||
chargerPhysicalPhases: Number,
|
||||
chargerSinglePhase: Boolean,
|
||||
minCurrent: Number,
|
||||
maxCurrent: Number,
|
||||
chargeCurrent: Number,
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
14
assets/js/components/MaterialIcon/Add.vue
Normal file
14
assets/js/components/MaterialIcon/Add.vue
Normal 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>
|
||||
17
assets/js/components/MaterialIcon/Loadpoint.vue
Normal file
17
assets/js/components/MaterialIcon/Loadpoint.vue
Normal 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>
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
121
assets/js/components/WelcomeIcons.vue
Normal file
121
assets/js/components/WelcomeIcons.vue
Normal 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>
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
3
assets/js/utils/deepClone.js
Normal file
3
assets/js/utils/deepClone.js
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
export default function (obj) {
|
||||
return JSON.parse(JSON.stringify(obj));
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
98
cmd/refs.go
Normal 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
|
||||
}
|
||||
85
cmd/setup.go
85
cmd/setup.go
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
83
core/loadpoint/config.go
Normal 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
|
||||
}
|
||||
|
|
@ -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))
|
||||
}
|
||||
|
|
|
|||
94
core/loadpoint/pollmode_enumer.go
Normal file
94
core/loadpoint/pollmode_enumer.go
Normal 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
39
core/loadpoint/types.go
Normal 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
|
||||
)
|
||||
|
|
@ -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,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
107
core/settings.go
107
core/settings.go
|
|
@ -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
111
core/settings/config.go
Normal 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
65
core/settings/database.go
Normal 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
18
core/settings/settings.go
Normal 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
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
130
i18n/de.toml
130
i18n/de.toml
|
|
@ -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"
|
||||
|
|
|
|||
113
i18n/en.toml
113
i18n/en.toml
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
251
server/http_config_loadpoint_handler.go
Normal file
251
server/http_config_loadpoint_handler.go
Normal 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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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))},
|
||||
|
|
|
|||
|
|
@ -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 }}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
277
tests/config-loadpoint.spec.js
Normal file
277
tests/config-loadpoint.spec.js
Normal 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/);
|
||||
});
|
||||
});
|
||||
101
tests/config-onboarding.spec.js
Normal file
101
tests/config-onboarding.spec.js
Normal 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
24
tests/config-one-lp.evcc.yaml
Executable 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:
|
||||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
};
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -9,5 +9,6 @@ const (
|
|||
Meter
|
||||
Vehicle
|
||||
Tariff
|
||||
Loadpoint
|
||||
Circuit
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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_
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue