Config UI: add missing config sections / partial yaml inputs (BC) (#13319)

This commit is contained in:
Michael Geers 2024-06-08 14:35:46 +02:00 • committed by GitHub
parent 62a1e223b6
commit 554ce1db60
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
133 changed files with 4405 additions and 1572 deletions

1
.gitignore vendored
View file

@ -10,6 +10,7 @@ __debug_bin*
!templates/**/*.yaml
!templates/**/*-schema.json
!util/templates/**/*.yaml
!assets/js/**/*.yaml
!package*.json
!evcc.dist.yaml
!tests/**/*.evcc.yaml

142
api/globalconfig/types.go Normal file
View file

@ -0,0 +1,142 @@
package globalconfig
import (
"fmt"
"net"
"strconv"
"time"
"github.com/evcc-io/evcc/charger/eebus"
"github.com/evcc-io/evcc/provider/mqtt"
"github.com/evcc-io/evcc/push"
"github.com/evcc-io/evcc/util/config"
"github.com/evcc-io/evcc/util/modbus"
)
type All struct {
Network Network
Log string
SponsorToken string
Plant string // telemetry plant id
Telemetry bool
Metrics bool
Profile bool
Levels map[string]string
Interval time.Duration
Database DB
Mqtt Mqtt
ModbusProxy []ModbusProxy
Javascript []Javascript
Go []Go
Influx Influx
EEBus eebus.Config
HEMS config.Typed
Messaging Messaging
Meters []config.Named
Chargers []config.Named
Vehicles []config.Named
Tariffs Tariffs
Site map[string]interface{}
Loadpoints []map[string]interface{}
Circuits []config.Named
}
type Javascript struct {
VM string
Script string
}
type Go struct {
VM string
Script string
}
type ModbusProxy struct {
Port int
ReadOnly string
modbus.Settings `mapstructure:",squash"`
}
type Mqtt struct {
mqtt.Config `mapstructure:",squash"`
Topic string `json:"topic"`
}
// Redacted implements the redactor interface used by the tee publisher
func (m Mqtt) Redacted() any {
// TODO add masked password
return struct {
Broker string `json:"broker"`
Topic string `json:"topic"`
User string `json:"user"`
ClientID string `json:"clientID"`
Insecure bool `json:"insecure"`
}{
Broker: m.Broker,
Topic: m.Topic,
User: m.User,
ClientID: m.ClientID,
Insecure: m.Insecure,
}
}
// Influx is the influx db configuration
type Influx struct {
URL string `json:"url"`
Database string `json:"database"`
Token string `json:"token"`
Org string `json:"org"`
User string `json:"user"`
Password string `json:"password"`
}
// Redacted implements the redactor interface used by the tee publisher
func (c Influx) Redacted() any {
// TODO add masked password
return struct {
URL string `json:"url"`
Database string `json:"database"`
Org string `json:"org"`
User string `json:"user"`
}{
URL: c.URL,
Database: c.Database,
Org: c.Org,
User: c.User,
}
}
type DB struct {
Type string
Dsn string
}
type Messaging struct {
Events map[string]push.EventTemplateConfig
Services []config.Typed
}
type Tariffs struct {
Currency string
Grid config.Typed
FeedIn config.Typed
Co2 config.Typed
Planner config.Typed
}
type Network struct {
Schema string `json:"schema"`
Host string `json:"host"`
Port int `json:"port"`
}
func (c Network) HostPort() string {
if c.Schema == "http" && c.Port == 80 || c.Schema == "https" && c.Port == 443 {
return c.Host
}
return net.JoinHostPort(c.Host, strconv.Itoa(c.Port))
}
func (c Network) URI() string {
return fmt.Sprintf("%s://%s", c.Schema, c.HostPort())
}

View file

@ -35,9 +35,11 @@
--evcc-red-rgb: 252, 68, 15;
--bs-gray-deep: #010322;
--bs-gray-dark: #28293e;
--bs-gray-darker: #151630;
--bs-gray-medium: #93949e;
--bs-gray-light: #b5b6c3;
--bs-gray-bright: #f3f3f7;
--bs-gray-brighter: #f9f9fb;
--evcc-grid: var(--bs-gray-dark);
--evcc-self: var(--evcc-dark-green);
@ -46,6 +48,7 @@
--evcc-export: var(--evcc-yellow);
--evcc-background: var(--bs-gray-bright);
--evcc-box: var(--bs-white);
--evcc-box-border: var(--bs-gray-brighter);
--evcc-default-text: var(--bs-gray-dark);
--evcc-gray: var(--bs-gray-medium);
--evcc-gray-50: #93949e80;
@ -65,6 +68,10 @@
--bs-warning: var(--evcc-orange);
--bs-warning-rgb: var(--evcc-orange-rgb);
--bs-danger-50: #dc354580;
--bs-danger: var(--evcc-red);
--bs-danger-rgb: var(--evcc-red-rgb);
--bs-danger: var(--evcc-red);
--bs-danger-rgb: var(--evcc-red-rgb);
@ -76,12 +83,14 @@
--bs-success: var(--evcc-primary);
--bs-success-rgb: var(--bs-primary-rgb);
--bs-code-color: var(--evcc-darkest-green);
--evcc-backdrop: #93949eaa; /* --bs-gray-medium + transparent */
}
:root.dark {
--evcc-grid: var(--bs-gray-medium);
--evcc-background: var(--bs-gray-deep);
--evcc-box: var(--bs-gray-dark);
--evcc-box-border: var(--vs-gray-darker);
--evcc-default-text: var(--bs-white);
--evcc-gray: var(--bs-gray-light);
--evcc-accent1: var(--evcc-yellow);
@ -90,15 +99,10 @@
--bs-primary: var(--evcc-dark-green);
--bs-border-color-translucent: rgba(255, 255, 255, 0.175);
--bs-code-color: var(--evcc-dark-green);
--evcc-backdrop: #000000aa; /* black + transparent */
color-scheme: dark;
}
html {
/* prevents content jump when scrollbar is activate */
width: 100vw;
overflow-x: hidden;
}
html.no-transitions * {
transition: none !important;
}
@ -322,7 +326,19 @@ a:hover {
}
.modal-backdrop.show {
opacity: 0.8;
opacity: 1;
}
.modal-backdrop {
--bs-backdrop-bg: var(--evcc-backdrop);
-webkit-backdrop-filter: blur(8px);
backdrop-filter: blur(8px);
}
.modal-backdrop.fade {
transition-property: opacity, backdrop-filter, background-color;
transition-duration: var(--evcc-transition-fast);
transition-timing-function: linear;
}
.modal-header {
@ -342,6 +358,13 @@ a:hover {
color: var(--evcc-default-text);
}
.modal-content h6 {
margin-top: 2rem;
margin-bottom: 1.5rem;
font-size: 1rem;
font-weight: bold;
}
/* breakpoint sm */
@media (min-width: 576px) {
.modal-content {
@ -362,6 +385,10 @@ a:hover {
margin: 0;
}
html:has(.modal.show) {
overflow-y: hidden;
}
.cursor-pointer {
cursor: pointer;
}
@ -514,6 +541,9 @@ input[type="time"]::-webkit-calendar-picker-indicator {
.w-sm-50 {
width: 50% !important;
}
.w-sm-25 {
width: 25% !important;
}
}
html.app .hide-in-app {
@ -553,13 +583,20 @@ html.app .modal-dialog {
.round-box {
border-radius: 1rem;
box-shadow: 0 0 0 0 var(--evcc-gray-50);
color: var(--evcc-default-text);
background: var(--evcc-box);
padding: 1rem;
border: 1px solid var(--evcc-gray-50);
transition: box-shadow var(--evcc-transition-fast) linear;
}
.round-box:hover {
border-color: var(--evcc-gray);
.round-box--error {
box-shadow: 0 0 0.5rem var(--bs-danger);
border: 1px solid var(--bs-danger);
}
.round-box--error .tags * {
color: var(--bs-danger-text-emphasis) !important;
}
.alert-danger code {
color: var(--evcc-darkest-green);
}

View file

@ -0,0 +1,26 @@
<template>
<YamlModal
id="circuitsModal"
:title="$t('config.circuits.title')"
:description="$t('config.circuits.description')"
docs="/docs/reference/configuration/circuits"
:defaultYaml="defaultYaml"
endpoint="/config/circuits"
size="md"
@changed="$emit('changed')"
/>
</template>
<script>
import YamlModal from "./YamlModal.vue";
import defaultYaml from "./defaultYaml/circuits.yaml?raw";
export default {
name: "CircuitsModal",
components: { YamlModal },
emits: ["changed"],
data() {
return { defaultYaml: defaultYaml.trim() };
},
};
</script>

View file

@ -0,0 +1,194 @@
<template>
<GenericModal
id="controlModal"
ref="modal"
:title="$t('config.control.title')"
data-testid="control-modal"
@open="open"
>
<p>{{ $t("config.control.description") }}</p>
<p class="text-danger" v-if="error">{{ error }}</p>
<form ref="form" class="container mx-0 px-0" @submit.prevent="save">
<FormRow
id="controlInterval"
:label="$t('config.control.labelInterval')"
:help="$t('config.control.descriptionInterval')"
example="30 s"
docsLink="/docs/reference/configuration/interval"
>
<div class="input-group w-50 w-sm-25">
<input
id="controlInterval"
v-model="values.interval"
type="number"
step="1"
min="1"
required
aria-describedby="controlIntervalUnit"
class="form-control text-end"
/>
<span id="controlIntervalUnit" class="input-group-text">s</span>
</div>
</FormRow>
<FormRow
id="controlResidualPower"
:label="$t('config.control.labelResidualPower')"
:help="$t('config.control.descriptionResidualPower')"
example="100 W"
docsLink="/docs/reference/configuration/site#residualpower"
>
<div class="input-group w-50 w-sm-25">
<input
id="controlResidualPower"
v-model="values.residualPower"
type="number"
step="1"
required
aria-describedby="controlResidualPowerUnit"
class="form-control text-end"
/>
<span id="controlResidualPowerUnit" class="input-group-text">W</span>
</div>
</FormRow>
<FormRow
id="controlMaxGridSupply"
:label="$t('config.control.labelMaxGridSupply')"
:help="$t('config.control.descriptionMaxGridSupply')"
docsLink="/docs/reference/configuration/site#maxgridsupplywhilebatterycharging"
>
<div class="input-group w-50 w-sm-25">
<input
id="controlMaxGridSupply"
v-model="values.maxGridSupply"
type="number"
step="1"
required
aria-describedby="controlMaxGridSupplyUnit"
class="form-control text-end"
/>
<span id="controlMaxGridSupplyUnit" class="input-group-text">W</span>
</div>
</FormRow>
<div class="mt-4 d-flex justify-content-between gap-2 flex-column flex-sm-row">
<button
type="button"
class="btn btn-link text-muted btn-cancel"
data-bs-dismiss="modal"
>
{{ $t("config.general.cancel") }}
</button>
<button
type="submit"
class="btn btn-primary order-1 order-sm-2 flex-grow-1 flex-sm-grow-0 px-4"
:disabled="saving || nothingChanged"
>
<span
v-if="saving"
class="spinner-border spinner-border-sm"
role="status"
aria-hidden="true"
></span>
{{ $t("config.general.save") }}
</button>
</div>
</form>
</GenericModal>
</template>
<script>
import GenericModal from "./../GenericModal.vue";
import FormRow from "./FormRow.vue";
import store from "../../store";
import api from "../../api";
export default {
name: "ControlModal",
components: { FormRow, GenericModal },
emits: ["changed"],
data() {
return {
saving: false,
error: "",
values: {},
serverValues: {},
};
},
computed: {
intervalChanged() {
return this.values.interval !== this.serverValues.interval;
},
residualPowerChanged() {
return this.values.residualPower !== this.serverValues.residualPower;
},
maxGridSupplyChanged() {
return this.values.maxGridSupply !== this.serverValues.maxGridSupply;
},
nothingChanged() {
return (
!this.intervalChanged && !this.residualPowerChanged && !this.maxGridSupplyChanged
);
},
},
methods: {
reset() {
const {
interval,
residualPower,
maxGridSupplyWhileBatteryCharging: maxGridSupply,
} = store?.state || {};
this.saving = false;
this.error = "";
this.values = { interval, residualPower, maxGridSupply };
this.serverValues = { ...this.values };
},
async open() {
this.reset();
},
async saveValue(name) {
let url = "";
if (name === "interval") {
url = `/config/interval/${encodeURIComponent(this.values.interval)}`;
} else if (name === "residualPower") {
url = `/residualpower/${encodeURIComponent(this.values.residualPower)}`;
} else if (name === "maxGridSupply") {
url = `/maxgridsupply/${encodeURIComponent(this.values.maxGridSupply)}`;
}
await api.post(url);
},
async save() {
this.saving = true;
this.error = "";
try {
if (this.intervalChanged) {
await this.saveValue("interval");
}
if (this.residualPowerChanged) {
await this.saveValue("residualPower");
}
if (this.maxGridSupplyChanged) {
await this.saveValue("maxGridSupply");
}
this.$emit("changed");
this.$refs.modal.close();
} catch (e) {
this.error = e.message;
}
this.saving = false;
},
},
};
</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;
}
.btn-cancel {
margin-left: -0.75rem;
}
</style>

View file

@ -1,21 +1,12 @@
<template>
<li class="root py-2 px-4">
<li class="root round-box" :class="{ 'round-box--error': error }">
<div class="d-flex align-items-center mb-2">
<div class="icon me-2">
<slot name="icon" />
</div>
<strong class="flex-grow-1 text-nowrap text-truncate">{{ name }}</strong>
<button
v-if="unconfigured"
type="button"
class="btn btn-sm btn-outline-secondary position-relative border-0 p-2"
:title="$t('config.main.new')"
@click="$emit('configure')"
>
<shopicon-regular-adjust size="s"></shopicon-regular-adjust>
</button>
<button
v-else-if="editable"
v-if="editable"
type="button"
class="btn btn-sm btn-outline-secondary position-relative border-0 p-2"
:title="$t('config.main.edit')"
@ -34,17 +25,14 @@
<shopicon-regular-adjust size="s"></shopicon-regular-adjust>
</button>
</div>
<div v-if="unconfigured" class="text-center py-3 evcc-gray">
{{ $t("config.main.unconfigured") }}
</div>
<slot v-else name="tags" />
<hr class="my-3 divide" />
<slot name="tags" />
</li>
</template>
<script>
import "@h2d2/shopicons/es/regular/adjust";
import "@h2d2/shopicons/es/regular/invoice";
import "@h2d2/shopicons/es/regular/edit";
import Tooltip from "bootstrap/js/dist/tooltip";
export default {
@ -52,14 +40,14 @@ export default {
props: {
name: String,
editable: Boolean,
unconfigured: Boolean,
error: Boolean,
},
data() {
return {
tooltip: null,
};
},
emits: ["edit", "configure"],
emits: ["edit"],
mounted() {
this.initTooltip();
},
@ -67,9 +55,6 @@ export default {
editable() {
this.initTooltip();
},
unconfigured() {
this.initTooltip();
},
},
methods: {
initTooltip() {
@ -88,18 +73,15 @@ export default {
.root {
display: block;
list-style-type: none;
min-height: 9rem;
color: var(--evcc-default-text);
border-radius: 1rem;
border: 1px solid var(--evcc-gray-50);
padding: 1rem 1rem 0.5rem;
transition: border-color var(--evcc-transition-fast) linear;
background: var(--evcc-box);
}
.root:hover {
border-color: var(--evcc-gray);
padding: 1rem 1.5rem;
min-height: 8rem;
}
.icon:empty {
display: none;
}
.divide {
margin-left: -1.5rem;
margin-right: -1.5rem;
}
</style>

View file

@ -1,16 +1,20 @@
<template>
<div v-if="tags" class="d-flex mb-2 flex-wrap">
<div v-if="tags" class="tags">
<span
v-for="(entry, index) in entries"
:key="index"
class="badge text-bg-secondary me-2 mb-2"
:class="{
'text-bg-secondary': !entry.error,
'text-bg-danger': entry.error,
}"
:data-testid="`device-tag-${entry.name}`"
class="d-flex gap-2 overflow-hidden text-truncate"
>
<div class="label overflow-hidden text-truncate flex-shrink-1 flex-grow-1">
{{ $t(`config.deviceValue.${entry.name}`) }}
</div>
<div
class="value overflow-hidden text-truncate"
:class="{ 'value--error': hasError(entry), 'value--muted': entry.value === false }"
>
<strong>{{ $t(`config.deviceValue.${entry.name}`) }}:</strong>
{{ fmtDeviceValue(entry) }}
</div>
</span>
</div>
</template>
@ -25,14 +29,20 @@ export default {
mixins: [formatter],
computed: {
entries() {
return Object.entries(this.tags).map(([name, { value, error }]) => {
return { name, value, error };
return Object.entries(this.tags).map(([name, { value, error, options }]) => {
return { name, value, error, options };
});
},
},
methods: {
hasError(entry) {
return !!entry.error;
},
fmtDeviceValue(entry) {
const { name, value } = entry;
const { name, value, options = {} } = entry;
if (value === null || value === undefined) {
return "";
}
switch (name) {
case "power":
return this.fmtKw(value);
@ -43,6 +53,8 @@ export default {
case "soc":
case "socLimit":
return this.fmtPercentage(value, 1);
case "temp":
return this.fmtTemperature(value);
case "odometer":
case "range":
return `${this.fmtNumber(value, 0)} km`;
@ -53,11 +65,43 @@ export default {
case "phasePowers":
return value.map((v) => this.fmtKw(v)).join(", ");
case "chargeStatus":
return value;
return this.$t(`config.deviceValue.chargeStatus${value}`);
case "gridPrice":
case "feedinPrice":
return this.fmtPricePerKWh(value, options.currency, true);
case "co2":
return this.fmtCo2Short(value);
case "configured":
return value
? this.$t("config.deviceValue.yes")
: this.$t("config.deviceValue.no");
}
return value;
},
},
};
</script>
<style scoped></style>
<style scoped>
.tags {
display: grid;
grid-template-columns: 1fr;
grid-gap: 0.5rem;
}
.label {
min-width: 4rem;
}
.value {
font-weight: bold;
color: var(--bs-primary);
}
.value:empty::after {
color: var(--evcc-gray);
content: "–";
}
.value--error {
color: var(--bs-danger);
}
.value--muted {
color: var(--evcc-gray) !important;
}
</style>

View file

@ -0,0 +1,25 @@
<template>
<YamlModal
id="eebusModal"
:title="$t('config.eebus.title')"
:description="$t('config.eebus.description')"
docs="/docs/reference/configuration/eebus"
:defaultYaml="defaultYaml"
endpoint="/config/eebus"
@changed="$emit('changed')"
/>
</template>
<script>
import YamlModal from "./YamlModal.vue";
import defaultYaml from "./defaultYaml/eebus.yaml?raw";
export default {
name: "EebusModal",
components: { YamlModal },
emits: ["changed"],
data() {
return { defaultYaml: defaultYaml.trim() };
},
};
</script>

View file

@ -12,13 +12,19 @@
</div>
<div class="form-text evcc-gray">
<div v-if="example">{{ $t("config.form.example") }}: {{ example }}</div>
<div v-if="help" v-html="helpHtml" class="text-gray"></div>
<div v-if="help">
<span class="text-gray" v-html="helpHtml"></span>
<a class="ms-1 text-gray" v-if="link" :href="link" target="_blank">
{{ $t("config.general.docsLink") }}
</a>
</div>
</div>
</div>
</template>
<script>
import linkify from "../../utils/linkify";
import { docsPrefix } from "../../i18n";
export default {
name: "FormRow",
@ -28,11 +34,15 @@ export default {
help: String,
optional: Boolean,
example: String,
docsLink: String,
},
computed: {
helpHtml() {
return linkify(this.help);
},
link() {
return this.docsLink ? `${docsPrefix()}${this.docsLink}` : null;
},
},
};
</script>

View file

@ -1,93 +1,111 @@
<template>
<div class="group round-box pt-4 px-4 pb-1">
<dl class="row" data-testid="generalconfig-title">
<dt class="col-sm-4">Title</dt>
<dd class="col-sm-8">
{{ title || "---" }}
<a
href="#"
class="ms-2 d-inline-block text-muted"
<div class="group round-box p-4">
<div class="config-entry" data-testid="generalconfig-title">
<strong class="config-label">{{ $t("config.general.title") }}</strong>
<div class="config-text">{{ title || "---" }}</div>
<button
class="config-button btn btn-link"
type="button"
:title="$t('config.main.edit')"
@click.prevent="openModal('titleModal')"
>
edit
</a>
<EditIcon size="xs" />
<TitleModal ref="titleModal" @changed="load" />
</dd>
</dl>
<dl class="row" data-testid="generalconfig-password">
<dt class="col-sm-4">Password</dt>
<dd class="col-sm-8">
*******
<a
href="#"
class="ms-2 d-inline-block text-muted"
</button>
</div>
<div class="config-entry" data-testid="generalconfig-password">
<strong class="config-label">{{ $t("config.general.password") }}</strong>
<div class="config-text">*******</div>
<button
class="config-button btn btn-link"
type="button"
:title="$t('config.main.edit')"
@click.prevent="openModal('passwordModal')"
>edit</a
>
</dd>
</dl>
<dl class="row" data-testid="generalconfig-telemetry">
<dt class="col-sm-4">Telemetry</dt>
<dd class="col-sm-8">
{{ telemetryEnabled ? "on" : "off" }}
<a
href="#"
class="ms-2 d-inline-block text-muted"
<EditIcon size="xs" />
</button>
</div>
<div class="config-entry" data-testid="generalconfig-telemetry">
<strong class="config-label">{{ $t("config.general.telemetry") }}</strong>
<div class="config-text">
{{ $t(`config.general.${telemetryEnabled ? "on" : "off"}`) }}
</div>
<button
class="config-button btn btn-link"
type="button"
:title="$t('config.main.edit')"
@click.prevent="openModal('globalSettingsModal')"
>
change
</a>
</dd>
</dl>
<dl class="row" data-testid="generalconfig-experimental">
<dt class="col-sm-4">Experimental</dt>
<dd class="col-sm-8">
{{ hiddenFeatures ? "on" : "off" }}
<a
href="#"
class="ms-2 d-inline-block text-muted"
<EditIcon size="xs" />
</button>
</div>
<div class="config-entry" data-testid="generalconfig-experimental">
<strong class="config-label">{{ $t("config.general.experimental") }}</strong>
<div class="config-text">
{{ $t(`config.general.${hiddenFeatures ? "on" : "off"}`) }}
</div>
<button
class="config-button btn btn-link"
type="button"
:title="$t('config.main.edit')"
@click.prevent="openModal('globalSettingsModal')"
>
change
</a>
</dd>
</dl>
<dl class="row wip">
<dt class="col-sm-4">API-Key</dt>
<dd class="col-sm-8">
*******
<a href="#" class="ms-2 d-inline-block text-muted" @click.prevent="todo">show</a>
</dd>
</dl>
<dl class="row wip">
<dt class="col-sm-4">Sponsoring</dt>
<dd class="col-sm-8">
<span class="text-primary"> valid </span>
<a href="#" class="ms-2 d-inline-block text-muted" @click.prevent="todo">change</a>
</dd>
</dl>
<dl class="row wip">
<dt class="col-sm-4">Server</dt>
<dd class="col-sm-8">
http://evcc.local:7070
<a href="#" class="ms-2 d-inline-block text-muted" @click.prevent="todo">edit</a>
</dd>
</dl>
<dl class="row wip">
<dt class="col-sm-4">Update Interval</dt>
<dd class="col-sm-8">
30s
<a href="#" class="ms-2 d-inline-block text-muted" @click.prevent="todo">edit</a>
</dd>
</dl>
<EditIcon size="xs" />
</button>
</div>
<div v-if="$hiddenFeatures()" class="config-entry" data-testid="generalconfig-sponsoring">
<strong class="config-label">{{ $t("config.sponsor.title") }} 🧪</strong>
<div class="config-text" :class="sponsorStatus.cssClass">
<span
v-if="sponsorStatus.expiresSoon"
class="d-inline-block me-1 p-1 rounded-circle bg-warning rounded-circle"
></span>
{{ sponsorStatus.name || "---" }}
</div>
<button
class="config-button btn btn-link"
type="button"
:title="$t('config.main.edit')"
@click.prevent="openModal('sponsorModal')"
>
<EditIcon size="xs" />
</button>
</div>
<div v-if="$hiddenFeatures()" class="config-entry" data-testid="generalconfig-network">
<strong class="config-label">{{ $t("config.network.title") }} 🧪</strong>
<div class="config-text">{{ networkStatus }}</div>
<button
class="config-button btn btn-link"
type="button"
:title="$t('config.main.edit')"
@click.prevent="openModal('networkModal')"
>
<EditIcon size="xs" />
</button>
</div>
<div v-if="$hiddenFeatures()" class="config-entry" data-testid="generalconfig-control">
<strong class="config-label">{{ $t("config.control.title") }} 🧪</strong>
<div class="config-text">{{ controlStatus }}</div>
<button
class="config-button btn btn-link"
type="button"
:title="$t('config.main.edit')"
@click.prevent="openModal('controlModal')"
>
<EditIcon size="xs" />
</button>
</div>
</div>
</template>
<script>
import Modal from "bootstrap/js/dist/modal";
import TitleModal from "./TitleModal.vue";
import EditIcon from "../MaterialIcon/Edit.vue";
import api from "../../api";
import settings from "../../settings";
import store from "../../store";
import formatter from "../../mixins/formatter";
export default {
name: "GeneralConfig",
@ -96,7 +114,8 @@ export default {
title: "",
};
},
components: { TitleModal },
components: { TitleModal, EditIcon },
mixins: [formatter],
emits: ["site-changed"],
async mounted() {
await this.load();
@ -108,6 +127,26 @@ export default {
hiddenFeatures() {
return settings.hiddenFeatures === true;
},
networkStatus() {
const { host, port } = store.state?.network || {};
return host ? `${host}:${port}` : `${port || ""}`;
},
controlStatus() {
const sec = store.state?.interval;
return sec ? this.fmtDuration(sec) : "";
},
sponsorStatus() {
const sponsor = store.state?.sponsor || {};
const { name, expiresSoon } = sponsor;
let cssClass = "";
if (expiresSoon) {
cssClass = "text-warning";
} else if (name) {
cssClass = "text-primary";
}
return { name, expiresSoon, cssClass };
},
},
methods: {
async changed() {
@ -116,8 +155,14 @@ export default {
},
async load() {
try {
let res = await api.get("/config/site");
let res = await api.get("/config/site", {
validateStatus: (code) => [200, 404].includes(code),
});
if (res.status === 200) {
this.title = res.data.result.title;
} else {
console.log("TODO: implement site endpoint in config error mode");
}
} catch (e) {
console.error(e);
}
@ -140,17 +185,39 @@ export default {
<style scoped>
.group {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(350px, 1fr));
grid-template-columns: repeat(auto-fill, minmax(225px, 1fr));
grid-gap: 2rem 5rem;
margin-bottom: 5rem;
align-items: start;
}
.wip {
opacity: 0.2;
display: none !important;
}
dt {
margin-bottom: 0.5rem;
.config-entry {
display: flex;
flex-wrap: nowrap;
justify-content: space-between;
align-items: center;
gap: 0.5rem;
}
dd {
margin-bottom: 1rem;
.config-label {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
flex-shrink: 1;
flex-grow: 0;
}
.config-text {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
flex-shrink: 1;
flex-grow: 1;
text-align: right;
}
.config-button {
margin-right: -1rem;
flex-shrink: 0;
color: var(--evcc-text-default);
}
</style>

View file

@ -0,0 +1,26 @@
<template>
<YamlModal
id="hemsModal"
:title="$t('config.hems.title')"
:description="$t('config.hems.description')"
docs="/docs/reference/configuration/hems"
:defaultYaml="defaultYaml"
endpoint="/config/hems"
size="md"
@changed="$emit('changed')"
/>
</template>
<script>
import YamlModal from "./YamlModal.vue";
import defaultYaml from "./defaultYaml/hems.yaml?raw";
export default {
name: "HemsModal",
components: { YamlModal },
emits: ["changed"],
data() {
return { defaultYaml: defaultYaml.trim() };
},
};
</script>

View file

@ -0,0 +1,122 @@
<template>
<JsonModal
id="influxModal"
:title="$t('config.influx.title')"
:description="$t('config.influx.description')"
docs="/docs/reference/configuration/influx"
endpoint="/config/influx"
state-key="influx"
data-testid="influx-modal"
@changed="$emit('changed')"
>
<template v-slot:default="{ values }">
<FormRow
id="influxUrl"
:label="$t('config.influx.labelUrl')"
example="http://localhost:8086"
>
<input
id="influxUrl"
v-model="values.url"
type="url"
class="form-control"
required
/>
</FormRow>
<FormRow
v-if="!showV1(values)"
id="influxOrg"
:label="$t('config.influx.labelOrg')"
example="home"
>
<input id="influxOrg" v-model="values.org" class="form-control" required />
</FormRow>
<FormRow
id="influxDatabase"
:label="$t(`config.influx.label${showV1(values) ? 'Database' : 'Bucket'}`)"
example="evcc"
>
<input
id="influxDatabase"
v-model="values.database"
class="form-control"
required
/>
</FormRow>
<FormRow
v-if="!showV1(values)"
id="influxToken"
:label="$t('config.influx.labelToken')"
:help="$t('config.influx.descriptionToken')"
>
<input id="influxToken" v-model="values.token" class="form-control" required />
</FormRow>
<FormRow
v-if="showV1(values)"
id="influxUser"
:label="$t('config.influx.labelUser')"
optional
>
<input id="influxUser" v-model="values.user" class="form-control" />
</FormRow>
<FormRow
v-if="showV1(values)"
id="influxPassword"
:label="$t('config.influx.labelPassword')"
optional
>
<input
id="influxPassword"
v-model="values.password"
class="form-control"
type="password"
autocomplete="off"
/>
</FormRow>
<p>
<button
v-if="showV1(values)"
class="btn btn-link btn-sm text-primary px-0"
type="button"
@click="
values.user = '';
values.password = '';
v1 = false;
"
>
{{ $t("config.influx.v2Support") }}
</button>
<button
v-else
class="btn btn-link btn-sm text-primary px-0"
type="button"
@click="
values.token = '';
v1 = true;
"
>
{{ $t("config.influx.v1Support") }}
</button>
</p>
</template>
</JsonModal>
</template>
<script>
import JsonModal from "./JsonModal.vue";
import FormRow from "./FormRow.vue";
export default {
name: "InfluxModal",
components: { FormRow, JsonModal },
emits: ["changed"],
data() {
return { v1: false };
},
methods: {
showV1(values) {
return this.v1 || values.user || values.password;
},
},
};
</script>

View file

@ -0,0 +1,174 @@
<template>
<GenericModal ref="modal" :title="title" @open="open">
<p v-if="description || docsLink">
<span v-if="description">{{ description + " " }}</span>
<a v-if="docsLink" :href="docsLink" target="_blank">
{{ $t("config.general.docsLink") }}
</a>
</p>
<p class="text-danger" v-if="error">
<span v-if="errorMessage" class="d-block">{{ errorMessage }}</span>
{{ error }}
</p>
<form ref="form" class="container mx-0 px-0" @submit.prevent="save">
<slot :values="values"></slot>
<div
v-if="!noButtons"
class="mt-4 d-flex justify-content-between gap-2 flex-column flex-sm-row"
>
<div
class="d-flex justify-content-between order-2 order-sm-1 gap-2 flex-grow-1 flex-sm-grow-0"
>
<button
type="button"
class="btn btn-link text-muted btn-cancel"
data-bs-dismiss="modal"
>
{{ $t("config.general.cancel") }}
</button>
<button
v-if="!disableRemove"
type="button"
class="btn btn-link text-danger"
:disabled="removing"
@click="remove"
>
<span
v-if="removing"
class="spinner-border spinner-border-sm"
role="status"
aria-hidden="true"
></span>
{{ $t("config.general.remove") }}
</button>
</div>
<button
type="submit"
class="btn btn-primary order-1 order-sm-2 flex-grow-1 flex-sm-grow-0 px-4"
:disabled="saving || nothingChanged"
>
<span
v-if="saving"
class="spinner-border spinner-border-sm"
role="status"
aria-hidden="true"
></span>
{{ $t("config.general.save") }}
</button>
</div>
</form>
</GenericModal>
</template>
<script>
import GenericModal from "../GenericModal.vue";
import api from "../../api";
import { docsPrefix } from "../../i18n";
import store from "../../store";
export default {
name: "JsonModal",
components: { GenericModal },
emits: ["changed", "open"],
data() {
return {
saving: false,
removing: false,
error: "",
values: {},
serverValues: {},
};
},
props: {
title: String,
description: String,
errorMessage: String,
docs: String,
endpoint: String,
disableRemove: Boolean,
noButtons: Boolean,
transformReadValues: Function,
stateKey: String,
saveMethod: { type: String, default: "post" },
},
computed: {
docsLink() {
return this.docs ? `${docsPrefix()}${this.docs}` : null;
},
nothingChanged() {
return JSON.stringify(this.values) === JSON.stringify(this.serverValues);
},
},
methods: {
reset() {
this.saving = false;
this.deleting = false;
this.error = "";
this.values = "";
this.serverValues = "";
},
async open() {
this.$emit("open");
this.reset();
await this.load();
},
async load() {
this.serverValues = this.stateKey ? store.state[this.stateKey] : store.state;
if (this.transformReadValues) {
this.serverValues = this.transformReadValues(this.serverValues);
}
this.values = { ...this.serverValues };
},
async save() {
this.saving = true;
this.error = "";
try {
const res = await api[this.saveMethod](this.endpoint, this.values, {
validateStatus: (code) => [200, 202, 400].includes(code),
});
if (res.status === 200 || res.status === 202) {
this.$emit("changed");
this.$refs.modal.close();
}
if (res.status === 400) {
this.error = res.data.error;
}
} catch (e) {
console.error(e);
}
this.saving = false;
},
async remove() {
this.removing = true;
this.error = "";
try {
const res = await api.delete(this.endpoint, {
validateStatus: (code) => [200, 400].includes(code),
});
if (res.status === 200) {
this.$emit("changed");
this.$refs.modal.close();
}
if (res.status === 400) {
this.error = res.data.error;
}
} catch (e) {
console.error(e);
}
this.removing = false;
},
},
};
</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;
}
.btn-cancel {
margin-left: -0.75rem;
}
</style>

View file

@ -0,0 +1,25 @@
<template>
<YamlModal
id="messagingModal"
:title="$t('config.messaging.title')"
:description="$t('config.messaging.description')"
docs="/docs/reference/configuration/messaging"
:defaultYaml="defaultYaml"
endpoint="/config/messaging"
@changed="$emit('changed')"
/>
</template>
<script>
import YamlModal from "./YamlModal.vue";
import defaultYaml from "./defaultYaml/messaging.yaml?raw";
export default {
name: "MessagingModal",
components: { YamlModal },
emits: ["changed"],
data() {
return { defaultYaml: defaultYaml.trim() };
},
};
</script>

View file

@ -0,0 +1,26 @@
<template>
<YamlModal
id="modbusProxyModal"
:title="$t('config.modbusproxy.title')"
:description="$t('config.modbusproxy.description')"
docs="/docs/reference/configuration/modbusproxy"
:defaultYaml="defaultYaml"
endpoint="/config/modbusproxy"
size="md"
@changed="$emit('changed')"
/>
</template>
<script>
import YamlModal from "./YamlModal.vue";
import defaultYaml from "./defaultYaml/modbusproxy.yaml?raw";
export default {
name: "ModbusProxyModal",
components: { YamlModal },
emits: ["changed"],
data() {
return { defaultYaml: defaultYaml.trim() };
},
};
</script>

View file

@ -0,0 +1,79 @@
<template>
<JsonModal
id="mqttModal"
:title="$t('config.mqtt.title')"
:description="$t('config.mqtt.description')"
docs="/docs/reference/configuration/mqtt"
endpoint="/config/mqtt"
state-key="mqtt"
data-testid="mqtt-modal"
@changed="$emit('changed')"
>
<template v-slot:default="{ values }">
<FormRow
id="mqttBroker"
:label="$t('config.mqtt.labelBroker')"
example="localhost:1883"
>
<input id="mqttBroker" v-model="values.broker" class="form-control" required />
</FormRow>
<h6>{{ $t("config.mqtt.publishing") }}</h6>
<FormRow
id="mqttTopic"
:label="$t('config.mqtt.labelTopic')"
:help="$t('config.mqtt.descriptionTopic')"
example="evcc"
optional
>
<input id="mqttTopic" v-model="values.topic" class="form-control" />
</FormRow>
<FormRow
id="mqttClientId"
:label="$t('config.mqtt.labelClientId')"
:help="$t('config.mqtt.descriptionClientId')"
optional
>
<input id="mqttClientId" v-model="values.clientID" class="form-control" />
</FormRow>
<h6>{{ $t("config.mqtt.authentication") }}</h6>
<FormRow id="mqttUser" :label="$t('config.mqtt.labelUser')" optional>
<input id="mqttUser" v-model="values.user" class="form-control" />
</FormRow>
<FormRow id="mqttPassword" :label="$t('config.mqtt.labelPassword')" optional>
<input
id="mqttPassword"
v-model="values.password"
class="form-control"
type="password"
autocomplete="off"
/>
</FormRow>
<FormRow id="mqttInsecure" :label="$t('config.mqtt.labelInsecure')">
<div class="d-flex">
<input
class="form-check-input"
id="mqttInsecure"
type="checkbox"
v-model="values.insecure"
/>
<label class="form-check-label ms-2" for="mqttInsecure">
{{ $t("config.mqtt.labelCheckInsecure") }}
</label>
</div>
</FormRow>
</template>
</JsonModal>
</template>
<script>
import JsonModal from "./JsonModal.vue";
import FormRow from "./FormRow.vue";
export default {
name: "MqttModal",
components: { FormRow, JsonModal },
emits: ["changed"],
};
</script>

View file

@ -0,0 +1,75 @@
<template>
<JsonModal
id="networkModal"
:title="$t('config.network.title')"
endpoint="/config/network"
state-key="network"
disable-remove
data-testid="network-modal"
@changed="$emit('changed')"
>
<template v-slot:default="{ values }">
<FormRow
id="networkSchema"
:label="$t('config.network.labelSchema')"
:help="$t('config.network.descriptionSchema')"
>
<div class="btn-group" role="group">
<input
id="networkSchemaHttp"
v-model="values.schema"
type="radio"
class="btn-check"
name="networkSchema"
value="http"
autocomplete="off"
/>
<label class="btn btn-outline-primary" for="networkSchemaHttp">HTTP</label>
<input
id="networkSchemaHttps"
v-model="values.schema"
type="radio"
class="btn-check"
name="networkSchema"
value="https"
autocomplete="off"
/>
<label class="btn btn-outline-primary" for="networkSchemaHttps">HTTPS</label>
</div>
</FormRow>
<FormRow
id="networkHost"
:label="$t('config.network.labelHost')"
:help="$t('config.network.descriptionHost')"
example="evcc.local"
>
<input id="networkHost" v-model="values.host" class="form-control" />
</FormRow>
<FormRow
id="networkPort"
:label="$t('config.network.labelPort')"
:help="$t('config.network.descriptionPort')"
>
<input
id="networkPort"
v-model="values.port"
class="form-control w-50 me-2 w-50"
type="number"
example="7070"
/>
</FormRow>
</template>
</JsonModal>
</template>
<script>
import JsonModal from "./JsonModal.vue";
import FormRow from "./FormRow.vue";
export default {
name: "NetworkModal",
components: { FormRow, JsonModal },
emits: ["changed"],
};
</script>

View file

@ -0,0 +1,96 @@
<template>
<JsonModal
id="sponsorModal"
:title="`${$t('config.sponsor.title')} 💚`"
:description="$t('config.sponsor.description')"
:error-message="$t('config.sponsor.error')"
endpoint="/config/sponsortoken"
:transform-read-values="transformReadValues"
data-testid="sponsor-modal"
size="lg"
:no-buttons="!showForm"
:disable-remove="!hasToken"
@changed="$emit('changed')"
@open="showForm = false"
>
<template v-slot:default="{ values }">
<SponsorTokenExpires v-bind="sponsor" />
<div class="mt-4 mb-3">
<Sponsor v-bind="sponsor" />
</div>
<button
v-if="!showForm"
type="button"
class="btn btn-link text-muted btn-form"
@click="showForm = !showForm"
>
{{ sponsorTokenLabel }}
</button>
<div v-else>
<hr />
<FormRow
id="sponsorToken"
:label="sponsorTokenLabel"
:help="
$t('config.sponsor.descriptionToken', { url: 'https://sponsor.evcc.io' })
"
docs-link="/docs/sponsorship"
class="mt-4"
>
<textarea
id="sponsorToken"
v-model="values.token"
required
rows="5"
spellcheck="false"
class="form-control"
/>
</FormRow>
</div>
</template>
</JsonModal>
</template>
<script>
import JsonModal from "./JsonModal.vue";
import FormRow from "./FormRow.vue";
import Sponsor, { VICTRON_DEVICE } from "../Sponsor.vue";
import SponsorTokenExpires from "../SponsorTokenExpires.vue";
import store from "../../store";
export default {
name: "SponsorModal",
components: { FormRow, JsonModal, Sponsor, SponsorTokenExpires },
emits: ["changed"],
data: () => ({
showForm: false,
}),
methods: {
transformReadValues() {
return { token: "" };
},
},
computed: {
sponsor() {
return store?.state?.sponsor;
},
hasToken() {
const name = this.sponsor?.name || "";
return name !== "" && name !== VICTRON_DEVICE;
},
sponsorTokenLabel() {
return this.hasToken
? this.$t("config.sponsor.changeToken")
: this.$t("config.sponsor.addToken");
},
},
};
</script>
<style scoped>
textarea {
font-family: var(--bs-font-monospace);
}
.btn-form {
margin-left: -0.75rem;
}
</style>

View file

@ -0,0 +1,26 @@
<template>
<YamlModal
id="tariffsModal"
:title="$t('config.tariffs.title')"
:description="$t('config.tariffs.description')"
docs="/docs/reference/configuration/tariffs"
:defaultYaml="defaultYaml"
endpoint="/config/tariffs"
data-testid="tariffs-modal"
@changed="$emit('changed')"
/>
</template>
<script>
import YamlModal from "./YamlModal.vue";
import defaultYaml from "./defaultYaml/tariffs.yaml?raw";
export default {
name: "TariffsModal",
components: { YamlModal },
emits: ["changed"],
data() {
return { defaultYaml: defaultYaml.trim() };
},
};
</script>

View file

@ -1,21 +1,17 @@
<template>
<div
class="alert my-4"
:class="{
'alert-secondary': unknown || running,
'alert-success': success,
'alert-danger': failed,
}"
role="alert"
>
<div class="test-result my-4 p-4">
<div class="d-flex justify-content-between align-items-center">
<div>
{{ $t("config.validation.label") }}:
<strong>
<span>{{ $t("config.validation.label") }}: </span>
<span v-if="unknown">{{ $t("config.validation.unknown") }}</span>
<span v-if="running">{{ $t("config.validation.running") }}</span>
<strong v-if="success">{{ $t("config.validation.success") }}</strong>
<strong v-if="failed">{{ $t("config.validation.failed") }}</strong>
</div>
<span class="text-success" v-if="success">
{{ $t("config.validation.success") }}
</span>
<span class="text-danger" v-if="failed">
{{ $t("config.validation.failed") }}
</span>
</strong>
<span
v-if="running"
class="spinner-border spinner-border-sm"
@ -26,13 +22,13 @@
{{ $t("config.validation.validate") }}
</a>
</div>
<hr v-if="error" />
<div v-if="error">
<hr class="divider" v-if="error" />
<div class="text-danger" v-if="error">
{{ error }}
</div>
<hr v-if="result" />
<hr class="divider" v-if="result" />
<div v-if="result">
<DeviceTags :tags="result" />
<DeviceTags :tags="result" class="success-values" />
</div>
</div>
</template>
@ -54,3 +50,14 @@ export default {
components: { DeviceTags },
};
</script>
<style scoped>
.test-result {
border: 1px solid var(--bs-border-color);
border-radius: var(--bs-border-radius);
}
.divider {
border-top-color: 1px solid var(--bs-border-color);
margin-left: -1.5rem;
margin-right: -1.5rem;
}
</style>

View file

@ -1,90 +1,39 @@
<template>
<GenericModal
<JsonModal
id="titleModal"
ref="modal"
:title="$t('config.title.title')"
endpoint="/config/site"
state-key="siteTitle"
save-method="put"
:transform-read-values="transformReadValues"
data-testid="title-modal"
disable-remove
@changed="$emit('changed')"
>
<p class="text-danger" v-if="error">{{ error }}</p>
<form ref="form" class="container mx-0 px-0">
<template v-slot:default="{ values }">
<FormRow
id="siteTitle"
:label="$t('config.title.label')"
:help="$t('config.title.description')"
>
<input id="siteTitle" v-model="title" class="form-control" />
<input id="siteTitle" v-model="values.title" class="form-control" />
</FormRow>
<div class="my-4 d-flex justify-content-between">
<button type="button" class="btn btn-link text-muted" data-bs-dismiss="modal">
{{ $t("config.general.cancel") }}
</button>
<button
type="submit"
class="btn btn-primary"
:disabled="saving"
@click.prevent="save"
>
<span
v-if="saving"
class="spinner-border spinner-border-sm"
role="status"
aria-hidden="true"
></span>
{{ $t("config.general.save") }}
</button>
</div>
</form>
</GenericModal>
</template>
</JsonModal>
</template>
<script>
import GenericModal from "../GenericModal.vue";
import JsonModal from "./JsonModal.vue";
import FormRow from "./FormRow.vue";
import api from "../../api";
export default {
name: "TitleModal",
components: { FormRow, GenericModal },
components: { FormRow, JsonModal },
emits: ["changed"],
data() {
return {
saving: false,
error: "",
title: "",
};
},
async mounted() {
await this.load();
},
methods: {
async load() {
try {
const { data } = await api.get("/config/site");
this.title = data.result.title;
} catch (e) {
console.error(e);
}
},
async save() {
this.saving = true;
this.error = "";
try {
await api.put("/config/site", { title: this.title });
this.$emit("changed");
this.$refs.modal.close();
} catch (e) {
console.error(e);
this.error = e.response.data.error;
}
this.saving = false;
transformReadValues(siteTitle) {
return { title: siteTitle };
},
},
};
</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;
}
</style>

View file

@ -1,5 +1,4 @@
<template>
<SponsorTokenExpires :sponsorTokenExpires="sponsorTokenExpires" />
<div class="container mx-0 px-0">
<FormRow id="settingsDesign" :label="$t('settings.theme.label')">
<SelectGroup
@ -40,7 +39,7 @@
/>
</FormRow>
<FormRow id="telemetryEnabled" :label="$t('settings.telemetry.label')">
<TelemetrySettings :sponsor="sponsor" class="mt-1 mb-0" />
<TelemetrySettings :sponsorActive="!!sponsor.name" class="mt-1 mb-0" />
</FormRow>
<FormRow id="hiddenFeaturesEnabled" :label="`${$t('settings.hiddenFeatures.label')} 🧪`">
<div class="form-check form-switch my-1">
@ -79,7 +78,6 @@
<script>
import TelemetrySettings from "../TelemetrySettings.vue";
import SponsorTokenExpires from "../SponsorTokenExpires.vue";
import FormRow from "../FormRow.vue";
import SelectGroup from "../SelectGroup.vue";
import {
@ -95,10 +93,9 @@ import { isApp } from "../../utils/native";
export default {
name: "UserInterfaceSettings",
components: { TelemetrySettings, FormRow, SelectGroup, SponsorTokenExpires },
components: { TelemetrySettings, FormRow, SelectGroup },
props: {
sponsor: String,
sponsorTokenExpires: Number,
sponsor: Object,
},
data: function () {
return {

View file

@ -0,0 +1,122 @@
<template>
<VueMonacoEditor
v-if="active"
class="editor"
language="yaml"
:theme="theme"
:options="options"
:value="modelValue"
ref="editor"
@update:value="$emit('update:modelValue', $event)"
@mount="ready"
>
<template #default> {{ $t("config.editor.loading") }} </template>
<template #failure>
<textarea
class="form-control"
:rows="lines"
:value="modelValue"
:disabled="disabled"
@input="$emit('update:modelValue', $event.target.value)"
/>
</template>
</VueMonacoEditor>
</template>
<script>
import { VueMonacoEditor, loader } from "@guolao/vue-monaco-editor";
const $html = document.querySelector("html");
export default {
name: "YamlEditor",
props: {
modelValue: String,
errorLine: Number,
disabled: Boolean,
},
components: { VueMonacoEditor },
emits: ["update:modelValue"],
data() {
return {
theme: "vs",
defaultOptions: {
automaticLayout: true,
formatOnType: true,
formatOnPaste: true,
minimap: { enabled: false },
showFoldingControls: "always",
scrollBeyondLastLine: false,
wordWrap: "off",
wrappingStrategy: "advanced",
overviewRulerLanes: 0,
},
active: true,
};
},
mounted() {
this.updateTheme();
$html.addEventListener("themechange", this.updateTheme);
},
watch: {
errorLine() {
// force rerender to update decorations
this.active = false;
this.$nextTick(() => (this.active = true));
},
},
computed: {
options() {
return { ...this.defaultOptions, readOnly: this.disabled };
},
lines() {
return (this.modelValue || "").split("\n").length;
},
},
beforeMount() {
loader.config({
paths: { vs: "https://cdn.jsdelivr.net/npm/monaco-editor@0.45.0/min/vs" },
});
loader.init();
},
unmounted() {
$html.removeEventListener("themechange", this.updateTheme);
},
methods: {
updateTheme() {
this.theme = $html.classList.contains("dark") ? "vs-dark" : "vs";
},
ready(editor, monaco) {
let decorations = null;
const highlight = () => {
decorations?.clear();
if (this.errorLine) {
decorations = editor.createDecorationsCollection([
{
range: new monaco.Range(this.errorLine, 1, this.errorLine, 1),
options: {
isWholeLine: true,
className: "error",
lineNumberClassName: "error",
marginClassName: "error",
},
},
]);
}
};
editor.onDidChangeModelContent(() => highlight());
highlight();
},
},
};
</script>
<style scoped>
.editor :global(.monaco-editor) {
--vscode-editor-background: var(--evcc-box) !important;
--vscode-editorGutter-background: var(--evcc-box-border) !important;
}
.editor :global(.error) {
background-color: var(--bs-danger-50) !important;
}
.editor {
border: 1px solid var(--bs-border-color);
}
</style>

View file

@ -0,0 +1,145 @@
<template>
<GenericModal ref="modal" :size="size" :title="title" @open="open">
<p v-if="description || docsLink">
<span v-if="description">{{ description + " " }}</span>
<a v-if="docsLink" :href="docsLink" target="_blank">
{{ $t("config.general.docsLink") }}
</a>
</p>
<p class="text-danger" v-if="error" data-testid="error">{{ error }}</p>
<form ref="form" class="container mx-0 px-0">
<div class="editor-container" :style="{ height }">
<YamlEditor v-model="yaml" class="editor" :errorLine="errorLine" />
</div>
<div class="mt-4 d-flex justify-content-between">
<button
type="button"
class="btn btn-link text-muted btn-cancel"
data-bs-dismiss="modal"
>
{{ $t("config.general.cancel") }}
</button>
<button
type="submit"
class="btn btn-primary"
:disabled="saving || nothingChanged"
@click.prevent="save"
>
<span
v-if="saving"
class="spinner-border spinner-border-sm"
role="status"
aria-hidden="true"
></span>
{{ $t("config.general.save") }}
</button>
</div>
</form>
</GenericModal>
</template>
<script>
import GenericModal from "../GenericModal.vue";
import api from "../../api";
import { docsPrefix } from "../../i18n";
import YamlEditor from "./YamlEditor.vue";
export default {
name: "YamlModal",
components: { GenericModal, YamlEditor },
emits: ["changed"],
data() {
return {
saving: false,
error: "",
errorLine: undefined,
yaml: "",
serverYaml: "",
};
},
props: {
title: String,
description: String,
docs: String,
endpoint: String,
defaultYaml: String,
size: { type: String, default: "xl" },
},
computed: {
docsLink() {
return `${docsPrefix()}${this.docs}`;
},
height() {
return Math.max(150, this.yaml.split("\n").length * 18) + 22 + "px";
},
nothingChanged() {
return this.yaml === this.serverYaml && this.yaml !== "";
},
},
methods: {
reset() {
this.yaml = "";
this.serverYaml = "";
this.error = "";
this.saving = false;
this.errorLine = undefined;
},
async open() {
this.reset();
await this.load();
},
async load() {
try {
const { data } = await api.get(this.endpoint);
this.serverYaml = data.result;
this.yaml = data.result || this.defaultYaml;
} catch (e) {
console.error(e);
}
},
async save() {
this.saving = true;
this.error = "";
this.errorLine = undefined;
try {
const data = this.yaml === this.defaultYaml ? "" : this.yaml;
const res = await api.post(this.endpoint, data, {
validateStatus: (code) => [200, 400].includes(code),
});
if (res.status === 200) {
this.$emit("changed");
this.$refs.modal.close();
}
if (res.status === 400) {
this.error = res.data.error;
this.errorLine = res.data.line;
}
} catch (e) {
console.error(e);
}
this.saving = false;
},
},
};
</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;
}
.editor-container {
margin: 0 -1rem 0 -1.25rem;
}
/* reset margins on lg */
@media (min-width: 992px) {
.editor-container {
margin: 0;
}
}
.btn-cancel {
margin-left: -0.75rem;
}
</style>

View file

@ -0,0 +1,18 @@
# - name: main # unique name, used as reference, e.g. as parent in other circuits
# title: Main Circuit # used in the UI
# maxCurrent: 63 # 63A main circuit breaker (optional)
# maxPower: 30000 # 30kW (optional)
# meter: grid # associated meter to monitor the power consumption (optional)
# parent: # no parent, this is the root circuit
# - name: garage # unique name, used as reference, e.g. to associate loadpoints
# title: Garage # used in the UI
# maxCurrent: 24 # allow individual phase use up to 24A
# maxPower: 11000 # limit total power to 11kW
# meter: garage # dedicated meter for the garage
# parent: main # parent to the main circuit
# - name: carport # unique name, used as reference, e.g. to associate loadpoints
# title: Carport # used in the UI
# maxCurrent: 32 # 32A circuit breaker
# maxPower: # no limit, only check current
# meter: # no meter, using data from loadpoints
# parent: main # parent to the main circuit

View file

@ -0,0 +1,12 @@
# shipid: EVCC-1234567890abcdef
# interfaces:
# - eth0
# certificate:
# public: |
# -----BEGIN CERTIFICATE-----
# 1234567890abcdef==
# -----END CERTIFICATE-----
# private: |
# -----BEGIN EC PRIVATE KEY-----
# 1234567890abcdef
# -----END EC PRIVATE KEY-----

View file

@ -0,0 +1,4 @@
# type: sma
# allowcontrol: false
# vendorid: AAAAAAAA
# deviceid: BBBBBBBB

View file

@ -0,0 +1,35 @@
# events:
# start:
# title: Charge started
# msg: Started charging in "${mode}" mode
# stop:
# title: Charge finished
# msg: Finished charging ${chargedEnergy:%.1fk}kWh in ${chargeDuration}.
# connect:
# title: Car connected
# msg: "Car connected at ${pvPower:%.1fk}kW PV"
# disconnect:
# title: Car disconnected
# msg: Car disconnected after ${connectedDuration}
# soc:
# title: Soc updated
# msg: Battery charged to ${vehicleSoc:%.0f}%
# guest:
# title: Unknown vehicle
# msg: Unknown vehicle, guest connected?
# services:
# - type: pushover
# app: # app id
# recipients:
# - # list of recipient ids
# - type: telegram
# token: # bot id
# chats:
# - # list of chat ids
# - type: email
# uri: smtp://<user>:<password>@<host>:<port>/?fromAddress=<from>&toAddresses=<to>
# - type: ntfy
# uri: https://<host>/<topics>
# priority: <priority>
# tags: <tags>

View file

@ -0,0 +1,9 @@
# - port: 5021
# uri: 192.0.2.2:502
# - port: 5022
# device: /dev/ttyUSB0
# baudrate: 9600
# comset: "8N1"
# - port: 5023
# uri: 192.0.2.3:502
# rtu: true

View file

@ -0,0 +1,13 @@
# currency: EUR
# grid: # price using energy from the grid
# type: fixed
# price: 0.294 # EUR/kWh
# feedin: # price for feeding solar energy to the grid
# type: fixed
# price: 0.08 # EUR/kWh
# co2: # carbon intensity forecast data
# type: grünstromindex
# zip: <zip>

View file

@ -3,7 +3,7 @@
<div class="container py-2">
<div class="d-flex justify-content-between">
<Version v-bind="version" />
<Savings v-bind="savings" :sponsor="sponsor" />
<Savings v-bind="savings" />
</div>
</div>
</footer>
@ -20,7 +20,6 @@ export default {
components: { Version, Savings },
props: {
version: Object,
sponsor: String,
savings: Object,
},
};

View file

@ -20,10 +20,7 @@
></button>
</div>
<div class="modal-body">
<UserInterfaceSettings
:sponsor="sponsor"
:sponsorTokenExpires="sponsorTokenExpires"
/>
<UserInterfaceSettings :sponsor="sponsor" />
</div>
</div>
</div>
@ -38,8 +35,7 @@ export default {
name: "GlobalSettingsModal",
components: { UserInterfaceSettings },
props: {
sponsor: String,
sponsorTokenExpires: Number,
sponsor: { type: Object, default: () => ({}) },
},
};
</script>

View file

@ -139,7 +139,7 @@
<script>
import Modal from "bootstrap/js/dist/modal";
import { docsPrefix } from "../i18n";
import api from "../api";
import { performRestart } from "../restart";
export default {
name: "HelpModal",
@ -159,11 +159,7 @@ export default {
modal.show();
},
async restartConfirmed() {
try {
await api.post("/system/shutdown");
} catch (e) {
alert("Unabled to restart server.");
}
await performRestart();
},
},
};

View file

@ -0,0 +1,17 @@
<template>
<svg :style="svgStyle" viewBox="0 0 24 24">
<path
fill="currentColor"
d="M4 21q-1.25 0-2.125-.875T1 18t.875-2.125T4 15q.225 0 .438.038t.412.087L8.9 9.55q-.425-.525-.663-1.175T8 7q0-1.65 1.175-2.825T12 3t2.825 1.175T16 7q0 .725-.25 1.375t-.675 1.175l4.075 5.575q.2-.05.413-.088T20 15q1.25 0 2.125.875T23 18t-.875 2.125T20 21t-2.125-.875T17 18q0-.475.138-.913t.387-.787l-4.05-5.575q-.125.05-.237.075t-.238.075v4.3q.875.3 1.438 1.075T15 18q0 1.25-.875 2.125T12 21t-2.125-.875T9 18q0-.975.563-1.737T11 15.174v-4.3q-.125-.05-.238-.075t-.237-.075L6.475 16.3q.25.35.388.788T7 18q0 1.25-.875 2.125T4 21m0-2q.425 0 .713-.288T5 18t-.288-.712T4 17t-.712.288T3 18t.288.713T4 19m8 0q.425 0 .713-.288T13 18t-.288-.712T12 17t-.712.288T11 18t.288.713T12 19m8 0q.425 0 .713-.288T21 18t-.288-.712T20 17t-.712.288T19 18t.288.713T20 19M12 9q.825 0 1.413-.587T14 7t-.587-1.412T12 5t-1.412.588T10 7t.588 1.413T12 9"
/>
</svg>
</template>
<script>
import icon from "../../mixins/icon";
export default {
name: "Circuits",
mixins: [icon],
};
</script>

View file

@ -0,0 +1,17 @@
<template>
<svg :style="svgStyle" viewBox="0 0 24 24">
<path
fill="currentColor"
d="M5 19h1.425L16.2 9.225L14.775 7.8L5 17.575zm-1 2q-.425 0-.712-.288T3 20v-2.425q0-.4.15-.763t.425-.637L16.2 3.575q.3-.275.663-.425t.762-.15t.775.15t.65.45L20.425 5q.3.275.437.65T21 6.4q0 .4-.138.763t-.437.662l-12.6 12.6q-.275.275-.638.425t-.762.15zM19 6.4L17.6 5zm-3.525 2.125l-.7-.725L16.2 9.225z"
/>
</svg>
</template>
<script>
import icon from "../../mixins/icon";
export default {
name: "Edit",
mixins: [icon],
};
</script>

View file

@ -0,0 +1,21 @@
<template>
<svg :style="svgStyle" viewBox="0 0 32 32">
<path
d="M15.998,8.095c1.369,-0 2.669,0.219 3.902,0.657c1.232,0.438 2.349,1.04 3.352,1.807c0.365,0.273 0.553,0.634 0.562,1.081c0.01,0.448 -0.15,0.835 -0.48,1.163c-0.31,0.31 -0.693,0.47 -1.149,0.48c-0.456,0.009 -0.867,-0.114 -1.232,-0.37c-0.693,-0.475 -1.46,-0.849 -2.299,-1.123c-0.84,-0.273 -1.725,-0.41 -2.656,-0.41c-0.93,-0 -1.815,0.137 -2.655,0.41c-0.839,0.274 -1.606,0.648 -2.299,1.123c-0.365,0.255 -0.776,0.374 -1.232,0.356c-0.456,-0.019 -0.84,-0.183 -1.15,-0.493c-0.31,-0.329 -0.465,-0.716 -0.465,-1.163c-0,-0.447 0.182,-0.807 0.547,-1.082c1.004,-0.766 2.122,-1.364 3.354,-1.793c1.232,-0.43 2.532,-0.644 3.9,-0.643m0,-6.57c2.281,0 4.43,0.374 6.447,1.123c2.017,0.748 3.828,1.806 5.433,3.175c0.365,0.31 0.557,0.693 0.575,1.15c0.019,0.456 -0.137,0.848 -0.465,1.177c-0.31,0.31 -0.693,0.47 -1.15,0.479c-0.456,0.01 -0.867,-0.132 -1.232,-0.425c-1.313,-1.076 -2.787,-1.911 -4.42,-2.504c-1.633,-0.592 -3.362,-0.889 -5.188,-0.89c-1.825,-0.001 -3.554,0.296 -5.186,0.89c-1.633,0.595 -3.106,1.429 -4.422,2.504c-0.365,0.292 -0.775,0.434 -1.232,0.425c-0.456,-0.008 -0.839,-0.168 -1.149,-0.479c-0.329,-0.329 -0.484,-0.721 -0.466,-1.177c0.019,-0.457 0.21,-0.84 0.575,-1.15c1.606,-1.369 3.418,-2.427 5.434,-3.175c2.017,-0.749 4.166,-1.123 6.446,-1.123"
fill="currentColor"
/>
<path
d="M15.992,23.961c-1.368,0 -2.669,-0.219 -3.901,-0.657c-1.232,-0.437 -2.35,-1.04 -3.353,-1.806c-0.365,-0.274 -0.552,-0.635 -0.561,-1.082c-0.01,-0.447 0.15,-0.835 0.479,-1.163c0.31,-0.31 0.694,-0.47 1.15,-0.479c0.456,-0.01 0.867,0.113 1.232,0.37c0.693,0.474 1.46,0.848 2.299,1.122c0.84,0.274 1.725,0.411 2.655,0.411c0.931,-0 1.816,-0.137 2.656,-0.411c0.839,-0.274 1.605,-0.648 2.299,-1.122c0.365,-0.256 0.776,-0.374 1.232,-0.356c0.456,0.018 0.839,0.182 1.149,0.493c0.311,0.328 0.466,0.716 0.466,1.162c-0,0.447 -0.183,0.808 -0.548,1.082c-1.003,0.767 -2.121,1.365 -3.353,1.794c-1.233,0.429 -2.533,0.643 -3.901,0.642m0,6.57c-2.281,0 -4.43,-0.374 -6.447,-1.122c-2.017,-0.748 -3.828,-1.807 -5.433,-3.176c-0.365,-0.31 -0.556,-0.693 -0.575,-1.149c-0.018,-0.456 0.137,-0.849 0.466,-1.177c0.31,-0.311 0.693,-0.47 1.149,-0.48c0.457,-0.009 0.867,0.132 1.232,0.425c1.314,1.077 2.788,1.911 4.421,2.504c1.632,0.593 3.362,0.89 5.187,0.89c1.826,0.001 3.555,-0.296 5.187,-0.89c1.632,-0.594 3.106,-1.429 4.421,-2.504c0.365,-0.292 0.776,-0.434 1.232,-0.425c0.456,0.009 0.84,0.169 1.15,0.48c0.328,0.328 0.484,0.721 0.465,1.177c-0.018,0.456 -0.21,0.839 -0.575,1.149c-1.605,1.369 -3.417,2.428 -5.434,3.176c-2.017,0.748 -4.165,1.122 -6.446,1.122"
fill="currentColor"
/>
</svg>
</template>
<script>
import icon from "../../mixins/icon";
export default {
name: "Eebus",
mixins: [icon],
};
</script>

View file

@ -0,0 +1,17 @@
<template>
<svg :style="svgStyle" viewBox="0 0 24 24">
<path
fill="currentColor"
d="M3 11q0 1.8 1.15 3.175T7.075 15.9l-.775-.775q-.275-.275-.275-.687t.275-.713q.3-.3.713-.3t.712.3L10.3 16.3q.3.3.3.7t-.3.7l-2.6 2.6q-.275.275-.7.275t-.725-.3Q6 20 6 19.6t.275-.7l.9-.95q-2.65-.3-4.412-2.287T1 11q0-2.925 2.038-4.962T8 4h2q.425 0 .713.288T11 5t-.288.713T10 6H8Q5.925 6 4.463 7.463T3 11m11 9q-.425 0-.712-.288T13 19v-5q0-.425.288-.712T14 13h7q.425 0 .713.288T22 14v5q0 .425-.288.713T21 20zm0-9q-.425 0-.712-.288T13 10V5q0-.425.288-.712T14 4h7q.425 0 .713.288T22 5v5q0 .425-.288.713T21 11zm1-2h5V6h-5z"
/>
</svg>
</template>
<script>
import icon from "../../mixins/icon";
export default {
name: "Hems",
mixins: [icon],
};
</script>

View file

@ -0,0 +1,17 @@
<template>
<svg :style="svgStyle" viewBox="0 0 24 24">
<path
fill="currentColor"
d="m23.778 14.482l-2.287-9.959c-.13-.545-.624-1.09-1.169-1.248L9.87.051C9.74 0 9.584 0 9.426 0c-.443 0-.909.18-1.222.443L.716 7.412C.3 7.776.092 8.504.222 9.024l2.445 10.662c.13.545.624 1.092 1.169 1.248l9.775 3.015c.13.051.285.051.443.051c.443 0 .91-.18 1.223-.443l8.007-7.435c.418-.39.624-1.092.494-1.64M10.962 2.417l7.175 2.21c.285.08.285.21 0 .286l-3.77.858c-.285.08-.674-.05-.883-.26l-2.626-2.834c-.235-.232-.184-.336.104-.26m4.47 12.872c.079.286-.105.444-.39.365l-7.748-2.392c-.285-.079-.338-.313-.13-.52l5.93-5.514c.209-.209.443-.13.52.156zM2.667 8.267l6.293-5.85c.21-.209.545-.18.754.025L12.86 5.85c.209.21.18.545-.026.754l-6.293 5.85c-.21.21-.545.181-.754-.025L2.64 9.024a.536.536 0 0 1 .026-.757zm1.536 9.284L2.54 10.244c-.08-.285.05-.34.234-.13L5.4 12.949c.209.209.285.624.209.909L4.462 17.55c-.079.285-.208.285-.26 0zm9.202 4.264l-8.217-2.522a.547.547 0 0 1-.364-.675l1.378-4.421a.547.547 0 0 1 .675-.365l8.216 2.522c.285.079.443.39.364.675L14.08 21.45a.553.553 0 0 1-.674.365zm7.279-5.98L15.2 20.93c-.209.209-.31.13-.234-.155l1.144-3.694c.079-.285.39-.573.674-.624l3.77-.858c.288-.076.339.054.13.234zm.598-1.09l-4.523 1.039a.534.534 0 0 1-.65-.39l-1.922-8.372a.534.534 0 0 1 .39-.65L19.1 5.335a.534.534 0 0 1 .649.39l1.923 8.371c.079.31-.102.596-.39.65Z"
/>
</svg>
</template>
<script>
import icon from "../../mixins/icon";
export default {
name: "Influx",
mixins: [icon],
};
</script>

View file

@ -0,0 +1,17 @@
<template>
<svg :style="svgStyle" viewBox="0 0 24 24">
<path
fill="currentColor"
d="M15 19v-1h-2q-.825 0-1.412-.587T11 16V8H9v1q0 .825-.587 1.413T7 11H4q-.825 0-1.412-.587T2 9V5q0-.825.588-1.412T4 3h3q.825 0 1.413.588T9 5v1h6V5q0-.825.588-1.412T17 3h3q.825 0 1.413.588T22 5v4q0 .825-.587 1.413T20 11h-3q-.825 0-1.412-.587T15 9V8h-2v8h2v-1q0-.825.588-1.412T17 13h3q.825 0 1.413.588T22 15v4q0 .825-.587 1.413T20 21h-3q-.825 0-1.412-.587T15 19M4 5v4zm13 10v4zm0-10v4zm0 4h3V5h-3zm0 10h3v-4h-3zM4 9h3V5H4z"
/>
</svg>
</template>
<script>
import icon from "../../mixins/icon";
export default {
name: "ModbusProxy",
mixins: [icon],
};
</script>

View file

@ -0,0 +1,33 @@
<template>
<svg :style="svgStyle" viewBox="-40 -40 400 400">
<g>
<path
fill="currentColor"
d="M7.1,180.6v117.1c0,8.4,6.8,15.3,15.3,15.3H142C141,239.8,80.9,180.7,7.1,180.6z"
/>
<path
fill="currentColor"
d="M7.1,84.1v49.8c99,0.9,179.4,80.7,180.4,179.1h51.7C238.2,186.6,134.5,84.2,7.1,84.1z"
/>
<path
fill="currentColor"
d="M312.9,297.6V193.5C278.1,107.2,207.3,38.9,119,7.1H22.4c-8.4,0-15.3,6.8-15.3,15.3v15
c152.6,0.9,276.6,124,277.6,275.6h13C306.1,312.9,312.9,306.1,312.9,297.6z"
/>
<path
fill="currentColor"
d="M272.6,49.8c14.5,14.4,28.6,31.7,40.4,47.8V22.4c0-8.4-6.8-15.3-15.3-15.3h-77.3
C238.4,19.7,256.6,33.9,272.6,49.8z"
/>
</g>
</svg>
</template>
<script>
import icon from "../../mixins/icon";
export default {
name: "Mqtt",
mixins: [icon],
};
</script>

View file

@ -0,0 +1,17 @@
<template>
<svg :style="svgStyle" viewBox="0 0 24 24">
<path
fill="currentColor"
d="M5 19q-.425 0-.712-.288T4 18t.288-.712T5 17h1v-7q0-2.075 1.25-3.687T10.5 4.2v-.7q0-.625.438-1.062T12 2t1.063.438T13.5 3.5v.7q2 .5 3.25 2.113T18 10v7h1q.425 0 .713.288T20 18t-.288.713T19 19zm7 3q-.825 0-1.412-.587T10 20h4q0 .825-.587 1.413T12 22m-4-5h8v-7q0-1.65-1.175-2.825T12 6T9.175 7.175T8 10zm-5-7q-.425 0-.712-.325t-.238-.75q.2-1.875 1.05-3.488t2.175-2.812q.325-.275.738-.25t.662.375t.2.75t-.375.7q-.975.925-1.6 2.15T4.075 9q-.05.425-.35.713T3 10m18 0q-.425 0-.725-.288T19.925 9q-.2-1.425-.825-2.65T17.5 4.2q-.325-.3-.375-.7t.2-.75t.663-.375t.737.25q1.325 1.2 2.175 2.812t1.05 3.488q.05.425-.237.75T21 10"
/>
</svg>
</template>
<script>
import icon from "../../mixins/icon";
export default {
name: "Notification",
mixins: [icon],
};
</script>

View file

@ -0,0 +1,17 @@
<template>
<svg :style="svgStyle" viewBox="0 0 24 24">
<path
fill="currentColor"
d="M9.825 20.7q-2.575-.725-4.2-2.837T4 13q0-1.425.475-2.713t1.35-2.362q.275-.3.675-.313t.725.313q.275.275.288.675t-.263.75q-.6.775-.925 1.7T6 13q0 2.025 1.188 3.613t3.062 2.162q.325.1.538.375t.212.6q0 .5-.35.788t-.825.162m4.35 0q-.475.125-.825-.175t-.35-.8q0-.3.213-.575t.537-.375q1.875-.6 3.063-2.175T18 13q0-2.5-1.75-4.25T12 7h-.075l.4.4q.275.275.275.7t-.275.7t-.7.275t-.7-.275l-2.1-2.1q-.15-.15-.212-.325T8.55 6t.063-.375t.212-.325l2.1-2.1q.275-.275.7-.275t.7.275t.275.7t-.275.7l-.4.4H12q3.35 0 5.675 2.325T20 13q0 2.725-1.625 4.85t-4.2 2.85"
/>
</svg>
</template>
<script>
import icon from "../../mixins/icon";
export default {
name: "Restart",
mixins: [icon],
};
</script>

View file

@ -1,23 +1,159 @@
<template>
<div>
<div class="modal-backdrop" v-if="offline" />
<div
class="fixed-bottom alert alert-secondary d-flex justify-content-center align-items-center mb-0 rounded-0"
class="fixed-bottom alert d-flex justify-content-center align-items-center mb-0 rounded-0 p-2"
:class="{ visible: visible, 'alert-danger': showError, 'alert-secondary': !showError }"
role="alert"
data-testid="bottom-banner"
>
<shopicon-regular-cloud class="me-2"></shopicon-regular-cloud>
<div v-if="restarting" class="d-flex align-items-center">
<button
class="btn btn-secondary me-2 btn-sm d-flex align-items-center"
type="button"
disabled
>
<span
class="spinner-border spinner-border-sm m-1 me-2"
role="status"
aria-hidden="true"
></span>
{{ $t("offline.restart") }}
</button>
{{ $t("offline.restarting") }}
</div>
<div v-else-if="restartNeeded" class="d-flex align-items-center">
<button
class="btn btn-secondary me-2 btn-sm d-flex align-items-center"
type="button"
@click="restart"
>
<Sync class="restart me-2" />
{{ $t("offline.restart") }}
</button>
{{ $t("offline.restartNeeded") }}
</div>
<div v-else-if="offline" class="d-flex align-items-center">
<CloudOffline class="m-2" />
{{ $t("offline.message") }}
</div>
<div
v-else-if="showError"
class="d-flex align-items-start container px-4 justify-content-center"
>
<shopicon-regular-car1
size="m"
class="fatal-icon flex-grow-0 flex-shrink-0"
></shopicon-regular-car1>
<div class="mx-4 mt-1">
<div>
<strong>
Error during startup. Check your configuration and restart.
</strong>
</div>
<div v-if="fatal">{{ fatal.error }}</div>
</div>
<button
type="button"
class="btn-close mt-1"
aria-label="Close"
@click="dismissed = true"
></button>
</div>
</div>
</div>
</template>
<script>
import "@h2d2/shopicons/es/regular/cloud";
import "@h2d2/shopicons/es/regular/car1";
import CloudOffline from "./MaterialIcon/CloudOffline.vue";
import Sync from "./MaterialIcon/Sync.vue";
import restart, { performRestart, restartComplete } from "../restart";
export default {
name: "OfflineIndicator",
components: {
CloudOffline,
Sync,
},
props: {
offline: Boolean,
fatal: Object,
},
data() {
return { dismissed: false };
},
watch: {
offline: function () {
if (!this.offline) {
restartComplete();
this.dismissed = false;
}
},
},
computed: {
restartNeeded() {
return restart.restartNeeded;
},
restarting() {
return restart.restarting;
},
visible() {
return this.offline || this.restartNeeded || this.restarting || this.showError;
},
showError() {
return (
!this.offline &&
!this.restartNeeded &&
!this.restarting &&
this.fatal?.error &&
!this.dismissed
);
},
},
methods: {
restart() {
performRestart();
},
},
};
</script>
<style>
.fixed-bottom {
/* $zindex-toast https://getbootstrap.com/docs/5.3/layout/z-index/ */
z-index: 1090 !important;
<style scoped>
.restart {
transform: scaleX(-1);
}
.alert {
opacity: 0;
transform: translateY(100%);
transition:
transform var(--evcc-transition-fast) ease-in,
opacity var(--evcc-transition-fast) ease-in;
min-height: 58px;
/* above backdrop, below modal https://getbootstrap.com/docs/5.3/layout/z-index/ */
z-index: 1054 !important;
}
.alert.visible {
opacity: 1;
transform: translateY(0);
}
.fatal-icon {
transform-origin: 60% 40%;
animation: swinging 3.5s ease-in-out infinite;
}
@keyframes swinging {
0% {
transform: translateY(6px) rotate(170deg);
}
50% {
transform: translateY(6px) rotate(185deg);
}
100% {
transform: translateY(6px) rotate(170deg);
}
}
.btn-close {
filter: none;
}
</style>

View file

@ -179,10 +179,9 @@
</div>
<div v-else class="my-4">
<LiveCommunity />
<TelemetrySettings :sponsor="sponsor" />
<TelemetrySettings :sponsorActive="!!sponsor.name" />
</div>
<Sponsor :sponsor="sponsor" />
<Sponsor v-bind="sponsor" />
</div>
</div>
</div>
@ -211,7 +210,7 @@ export default {
props: {
statistics: { type: Object, default: () => ({}) },
co2Configured: Boolean,
sponsor: String,
sponsor: Object,
currency: String,
},
data() {

View file

@ -17,7 +17,11 @@
<Energyflow 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>
<Loadpoints
v-else
class="mt-1 mt-sm-2 flex-grow-1"
:loadpoints="loadpoints"
:vehicles="vehicleList"
@ -93,10 +97,10 @@ export default {
hasUpdater: Boolean,
uploadMessage: String,
uploadProgress: Number,
sponsor: String,
sponsorTokenExpires: Number,
sponsor: { type: Object, default: () => ({}) },
smartCostType: String,
smartCostActive: Boolean,
fatal: Object,
},
computed: {
batteryConfigured: function () {
@ -143,8 +147,8 @@ export default {
uploadMessage: this.uploadMessage,
uploadProgress: this.uploadProgress,
},
sponsor: this.sponsor,
savings: {
sponsor: this.sponsor,
statistics: this.statistics,
co2Configured: this.tariffCo2 !== undefined,
priceConfigured: this.tariffGrid !== undefined,
@ -164,4 +168,6 @@ export default {
flex-grow: 1;
z-index: 1;
}
.fatal {
}
</style>

View file

@ -14,7 +14,7 @@ import Sponsor from "./Sponsor.vue";
<Sponsor sponsor="naltatis" />
</Variant>
<Variant title="victron device">
<Sponsor sponsor="victron-device" />
<Sponsor sponsor="victron" />
</Variant>
</Story>
</template>

View file

@ -7,13 +7,14 @@
{{ $t(`footer.sponsor.${isVictronDevice ? "titleVictron" : "titleSponsor"}`) }}
</p>
<p class="mb-3">
{{ $t(`footer.sponsor.${isVictronDevice ? "victron" : "thanks"}`, { sponsor }) }}
{{ $t(`footer.sponsor.${isVictronDevice ? "victron" : "thanks"}`, { sponsor: name }) }}
</p>
<div
class="d-flex justify-content-center align-items-center flex-column flex-lg-row align-items-lg-baseline justify-content-lg-start"
>
<button
ref="confetti"
type="button"
class="btn btn btn-outline-primary mb-2 confetti-button bg-evcc rounded flex-shrink-0"
@click="surprise"
>
@ -33,7 +34,7 @@
</a>
</div>
</div>
<div v-if="!sponsor || isTrial">
<div v-if="!name || isTrial">
<p class="fw-bold mb-1">
<shopicon-regular-clock
v-if="isTrial"
@ -67,20 +68,25 @@ import "@h2d2/shopicons/es/regular/stars";
import "@h2d2/shopicons/es/regular/clock";
import { docsPrefix } from "../i18n";
export const TRIAL = "trial";
export const VICTRON_DEVICE = "victron";
export default {
name: "Sponsor",
props: {
sponsor: String,
name: String,
expiresAt: String,
expiresSoon: Boolean,
},
computed: {
isTrial() {
return this.sponsor === "trial";
return this.name === TRIAL;
},
isVictronDevice() {
return this.sponsor === "victron-device";
return this.name === VICTRON_DEVICE;
},
isIndividual() {
return this.sponsor && !this.isTrial && !this.isVictronDevice;
return this.name && !this.isTrial && !this.isVictronDevice;
},
sponsorLink() {
return `${docsPrefix()}/docs/sponsorship`;

View file

@ -4,8 +4,8 @@ import SponsorTokenExpires from "./SponsorTokenExpires.vue";
<template>
<Story title="Sponsor Token Expires">
<Variant title="in 9 days">
<SponsorTokenExpires :sponsorTokenExpires="768423" />
<Variant title="some day">
<SponsorTokenExpires :sponsorSoon="true" expiresAt="2055-01-01T00:00:00Z" />
</Variant>
<Variant title="empty">
<SponsorTokenExpires />

View file

@ -1,6 +1,6 @@
<template>
<div v-if="sponsorTokenExpires" class="alert alert-warning" role="alert">
<i18n-t tag="div" class="mb-2" keypath="settings.sponsorToken.expires">
<div v-if="expiresSoon" class="alert alert-warning my-4" role="alert">
<i18n-t tag="div" keypath="settings.sponsorToken.expires">
<template #inXDays>
{{ inXDays }}
</template>
@ -11,7 +11,7 @@
</template>
</i18n-t>
<em>
<em class="d-block mt-2" v-if="!isTrial">
{{ $t("settings.sponsorToken.hint") }}
</em>
</div>
@ -19,16 +19,22 @@
<script>
import formatter from "../mixins/formatter";
import { TRIAL } from "./Sponsor.vue";
export default {
name: "SponsorTokenExpires",
mixins: [formatter],
props: {
sponsorTokenExpires: Number,
expiresSoon: Boolean,
expiresAt: String,
name: String,
},
computed: {
inXDays() {
return this.fmtTimeAgo(this.sponsorTokenExpires * 1000);
return this.fmtTimeAgo(new Date(this.expiresAt) - new Date());
},
isTrial() {
return this.name === TRIAL;
},
},
};

View file

@ -7,13 +7,13 @@
class="form-check-input"
type="checkbox"
role="switch"
:disabled="!sponsor"
:disabled="!sponsorActive"
@change="change"
/>
<div class="form-check-label">
<label for="telemetryEnabled">
{{ $t("footer.telemetry.optIn") }}
<i18n-t v-if="sponsor" tag="span" keypath="footer.telemetry.optInMoreDetails">
<i18n-t v-if="sponsorActive" tag="span" keypath="footer.telemetry.optInMoreDetails">
<a :href="docsLink" target="_blank">
{{ $t("footer.telemetry.optInMoreDetailsLink") }}
</a>
@ -40,7 +40,7 @@ function parseMarkdown(markdownText) {
export default {
name: "TelemetrySettings",
props: { sponsor: String },
props: { sponsorActive: Boolean },
data() {
return {
error: null,

View file

@ -36,7 +36,7 @@ import TopNavigation from "./TopNavigation.vue";
/>
</Variant>
<Variant title="token expires">
<TopNavigation :sponsorTokenExpires="768423" />
<TopNavigation :sponsor="{ expiresSoon: true }" />
</Variant>
</Story>
</template>

View file

@ -10,7 +10,8 @@
>
<span
v-if="showBadge"
class="position-absolute top-0 start-100 translate-middle p-2 bg-danger border border-light rounded-circle"
class="position-absolute top-0 start-100 translate-middle p-2 rounded-circle"
:class="badgeClass"
>
<span class="visually-hidden">action required</span>
</span>
@ -34,10 +35,6 @@
data-testid="topnavigation-settings"
@click="openSettingsModal"
>
<span
v-if="sponsorTokenExpires"
class="d-inline-block p-1 rounded-circle bg-danger border border-light rounded-circle"
></span>
{{ $t("settings.title") }}
</button>
</li>
@ -53,6 +50,11 @@
</li>
<li>
<router-link class="dropdown-item" to="/config" active-class="active">
<span
v-if="showBadge"
class="d-inline-block p-1 rounded-circle bg-warning rounded-circle"
:class="badgeClass"
></span>
{{ $t("config.main.title") }}
</router-link>
</li>
@ -75,7 +77,8 @@
>
<span
v-if="!login.loggedIn"
class="d-inline-block p-1 rounded-circle bg-danger border border-light rounded-circle"
class="d-inline-block p-1 rounded-circle border border-light rounded-circle"
:class="badgeClass"
></span>
{{ login.title }}
{{ $t(login.loggedIn ? "main.provider.logout" : "main.provider.login") }}
@ -119,7 +122,6 @@ import "@h2d2/shopicons/es/regular/menu";
import "@h2d2/shopicons/es/regular/newtab";
import collector from "../mixins/collector";
import { logout, isLoggedIn, openLoginModal } from "../auth";
import baseAPI from "../baseapi";
import { isApp, sendToApp } from "../utils/native";
@ -133,9 +135,14 @@ export default {
return {};
},
},
sponsor: String,
sponsorTokenExpires: Number,
sponsor: {
type: Object,
default: () => {
return {};
},
},
battery: Array,
fatal: Object,
},
data() {
return {
@ -161,7 +168,13 @@ export default {
return this.logoutCount > 0;
},
showBadge() {
return this.loginRequired || this.sponsorTokenExpires;
return this.loginRequired || this.sponsor.expiresSoon || this.fatal?.error;
},
badgeClass() {
if (this.fatal?.error) {
return "bg-danger";
}
return "bg-warning";
},
batteryModalAvailable() {
return this.batteryConfigured;

View file

@ -3,13 +3,14 @@ export default {
size: {
type: String,
validator: function (value) {
return ["s", "m", "l", "xl"].includes(value);
return ["xs", "s", "m", "l", "xl"].includes(value);
},
},
},
computed: {
svgStyle() {
const sizes = {
xs: "16px",
s: "24px",
m: "32px",
l: "48px",

23
assets/js/restart.js Normal file
View file

@ -0,0 +1,23 @@
import { reactive } from "vue";
import api from "./api";
const restart = reactive({
restartNeeded: false,
restarting: false,
});
export async function performRestart() {
try {
await api.post("/system/shutdown");
restart.restarting = true;
} catch (e) {
alert("Unabled to restart server.");
}
}
export function restartComplete() {
restart.restarting = false;
restart.restartNeeded = false;
}
export default restart;

View file

@ -54,10 +54,6 @@ export default function setupRouter(i18n) {
component: () => import("./views/Log.vue"),
beforeEnter: ensureAuth,
},
{
path: "/error",
component: () => import("./views/StartupError.vue"),
},
],
});
router.beforeEach(async () => {

View file

@ -7,7 +7,7 @@
<HelpModal />
<PasswordModal />
<LoginModal />
<OfflineIndicator v-if="offline" />
<OfflineIndicator v-bind="offlineIndicatorProps" />
</div>
</template>
@ -64,15 +64,6 @@ export default {
offline: function () {
updateAuthStatus();
},
startupErrors: function (now) {
if (now) {
console.log("startup errors detected. redirecting to error page");
this.$router.push("/error");
} else {
console.log("startup errors resolved. redirecting to home page");
this.$router.push("/");
}
},
},
computed: {
version: function () {
@ -87,8 +78,8 @@ export default {
batterySettingsProps() {
return this.collectProps(BatterySettingsModal, store.state);
},
startupErrors: function () {
return store.state.fatal?.length > 0;
offlineIndicatorProps() {
return this.collectProps(OfflineIndicator, store.state);
},
},
mounted: function () {

View file

@ -2,76 +2,48 @@
<div class="root safe-area-inset">
<div class="container px-4">
<TopHeader :title="$t('config.main.title')" />
<div class="wrapper">
<div
v-if="dirty || restarting"
class="alert alert-secondary d-flex justify-content-between align-items-center my-4"
role="alert"
>
<div v-if="restarting">
<strong>{{ $t("config.system.restartingMessage") }}</strong>
{{ $t("config.system.restartingDescription") }}
</div>
<div v-else>
<strong>{{ $t("config.system.restartRequiredMessage") }}</strong>
{{ $t("config.system.restartRequiredDescription") }}
</div>
<button
type="button"
class="btn btn-outline-dark btn-sm"
:disabled="restarting || offline"
@click="restart"
>
<span
v-if="restarting || offline"
class="spinner-border spinner-border-sm"
role="status"
aria-hidden="true"
></span>
<span v-else>{{ $t("config.system.restart") }}</span>
</button>
<div class="wrapper pb-5">
<div class="alert alert-danger my-4 pb-0" role="alert" v-if="$hiddenFeatures()">
<p>
<strong>Experimental! 🧪</strong>
Only use these features if you are in the mood for adventure and not afraid
of debugging. Unexpected things and data loss may happen.
</p>
<p>
We are in the progress of replacing <code>evcc.yaml</code> with UI-based
configuration. Any changes made here will be written to the database. After
that, the corresponding <code>evcc.yaml</code>-values (e.g. network
settings) will be ignored.
</p>
<p class="mb-1"><strong>Missing features</strong></p>
<ul>
<li>grid meter</li>
<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>
</ul>
<p>
<strong>Migration and repair.</strong> Run <code>evcc migrate</code> to copy
configuration from <code>evcc.yaml</code> to the database. Existing database
configurations will be overwritten. Session and statistics data will not be
touched. Run <code>evcc migrate --reset</code> to remove all database
configurations.
</p>
</div>
<h2 class="my-4 mt-5">{{ $t("config.section.general") }}</h2>
<GeneralConfig @site-changed="siteChanged" />
<h2 class="my-4 mt-5">{{ $t("config.section.system") }}</h2>
<div class="round-box p-4 d-flex gap-4">
<router-link to="/log" class="btn btn-outline-secondary">
{{ $t("config.system.logs") }}
</router-link>
<button
class="btn btn-outline-danger"
:disabled="restarting || offline"
@click="restart"
>
<span
v-if="restarting || offline"
class="spinner-border spinner-border-sm"
role="status"
aria-hidden="true"
></span>
{{ $t("config.system.restart") }}
</button>
</div>
<div v-if="$hiddenFeatures()">
<hr class="my-5" />
<div class="alert alert-danger my-4" role="alert">
<strong>Highly experimental!</strong> Only play around with these settings
if you know what you're doing. Otherwise you might have to reset or manually
repair your database.
</div>
<h2 class="my-4 mt-5">Grid, PV & Battery Systems</h2>
<h2 class="my-4 mt-5">{{ $t("config.section.grid") }} 🧪</h2>
<ul class="p-0 config-list">
<DeviceCard
:name="gridMeter?.config?.template || 'Grid meter'"
:unconfigured="!gridMeter"
:name="$t('config.grid.title')"
:editable="!!gridMeter?.id"
:error="deviceError('meter', gridMeter?.name)"
data-testid="grid"
@configure="addMeter('grid')"
@edit="editMeter(gridMeter.id, 'grid')"
>
<template #icon>
@ -81,11 +53,29 @@
<DeviceTags :tags="deviceTags('meter', gridMeter?.name)" />
</template>
</DeviceCard>
<DeviceCard
:name="$t('config.tariffs.title')"
editable
:error="fatalClass === 'tariff'"
data-testid="tariffs"
@edit="openModal('tariffsModal')"
>
<template #icon>
<shopicon-regular-receivepayment></shopicon-regular-receivepayment>
</template>
<template #tags>
<DeviceTags :tags="tariffTags" />
</template>
</DeviceCard>
</ul>
<h2 class="my-4 mt-5">{{ $t("config.section.meter") }} 🧪</h2>
<ul class="p-0 config-list">
<DeviceCard
v-for="meter in pvMeters"
:key="!!meter.name"
:key="meter.name"
:name="meter.config?.template || 'Solar system'"
:editable="!!meter.id"
:error="deviceError('meter', meter.name)"
data-testid="pv"
@edit="editMeter(meter.id, 'pv')"
>
@ -101,6 +91,7 @@
:key="meter.name"
:name="meter.config?.template || 'Battery storage'"
:editable="!!meter.id"
:error="deviceError('meter', meter.name)"
data-testid="battery"
@edit="editMeter(meter.id, 'battery')"
>
@ -114,47 +105,13 @@
<AddDeviceButton :title="$t('config.main.addPvBattery')" @add="addMeter" />
</ul>
<h2 class="my-4 wip">Tariffs</h2>
<ul class="p-0 config-list wip">
<DeviceCard
name="Grid"
unconfigured
data-testid="tariff-grid"
@configure="todo"
>
<template #icon>
<shopicon-regular-money></shopicon-regular-money>
</template>
</DeviceCard>
<DeviceCard
name="Feed-in"
unconfigured
data-testid="tariff-feedin"
@configure="todo"
>
<template #icon>
<shopicon-regular-receivepayment></shopicon-regular-receivepayment>
</template>
</DeviceCard>
<DeviceCard
name="CO₂ estimate"
unconfigured
data-testid="tariff-co2"
@configure="todo"
>
<template #icon>
<shopicon-regular-eco1></shopicon-regular-eco1>
</template>
</DeviceCard>
</ul>
<h2 class="my-4 wip">Charge Points</h2>
<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"
>
@ -172,14 +129,15 @@
/>
</ul>
<h2 class="my-4">Vehicles</h2>
<h2 class="my-4">{{ $t("config.section.vehicles") }} 🧪</h2>
<div>
<ul class="p-0 config-list">
<DeviceCard
v-for="vehicle in vehicles"
:key="vehicle.id"
: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)"
>
@ -196,42 +154,109 @@
@click="addVehicle"
/>
</ul>
</div>
<h2 class="my-4 wip">Integrations</h2>
<ul class="p-0 config-list wip">
<DeviceCard name="MQTT" unconfigured data-testid="mqtt" @configure="todo">
<template #icon>
<shopicon-regular-fastdelivery1></shopicon-regular-fastdelivery1>
</template>
</DeviceCard>
<h2 class="my-4 mt-5">{{ $t("config.section.integrations") }} 🧪</h2>
<ul class="p-0 config-list">
<DeviceCard
name="Notifications"
unconfigured
data-testid="eebus"
@configure="todo"
:name="$t('config.mqtt.title')"
editable
:error="fatalClass === 'mqtt'"
data-testid="mqtt"
@edit="openModal('mqttModal')"
>
<template #icon>
<shopicon-regular-sendit></shopicon-regular-sendit>
<template #icon><MqttIcon /></template>
<template #tags>
<DeviceTags :tags="mqttTags" />
</template>
</DeviceCard>
<DeviceCard
name="InfluxDB"
unconfigured
:name="$t('config.messaging.title')"
editable
:error="fatalClass === 'messenger'"
data-testid="messaging"
@edit="openModal('messagingModal')"
>
<template #icon><NotificationIcon /></template>
<template #tags>
<DeviceTags :tags="yamlTags('messaging')" />
</template>
</DeviceCard>
<DeviceCard
:name="$t('config.influx.title')"
editable
:error="fatalClass === 'influx'"
data-testid="influx"
@configure="todo"
@edit="openModal('influxModal')"
>
<template #icon>
<shopicon-regular-diagram></shopicon-regular-diagram>
<template #icon><InfluxIcon /></template>
<template #tags>
<DeviceTags :tags="influxTags" />
</template>
</DeviceCard>
<DeviceCard name="EEBus" unconfigured data-testid="eebus" @configure="todo">
<template #icon>
<shopicon-regular-polygon></shopicon-regular-polygon>
<DeviceCard
:name="`${$t('config.eebus.title')} 🧪`"
editable
:error="fatalClass === 'eebus'"
data-testid="eebus"
@edit="openModal('eebusModal')"
>
<template #icon><EebusIcon /></template>
<template #tags>
<DeviceTags :tags="yamlTags('eebus')" />
</template>
</DeviceCard>
<DeviceCard
:name="`${$t('config.circuits.title')} 🧪`"
editable
:error="fatalClass === 'circuit'"
data-testid="circuits"
@edit="openModal('circuitsModal')"
>
<template #icon><CircuitsIcon /></template>
<template #tags>
<DeviceTags :tags="yamlTags('circuits')" />
</template>
</DeviceCard>
<DeviceCard
:name="$t('config.modbusproxy.title')"
editable
:error="fatalClass === 'modbusproxy'"
data-testid="modbusproxy"
@edit="openModal('modbusProxyModal')"
>
<template #icon><ModbusProxyIcon /></template>
<template #tags>
<DeviceTags :tags="yamlTags('modbusproxy')" />
</template>
</DeviceCard>
<DeviceCard
:name="$t('config.hems.title')"
editable
:error="fatalClass === 'hems'"
data-testid="hems"
@edit="openModal('hemsModal')"
>
<template #icon><HemsIcon /></template>
<template #tags>
<DeviceTags :tags="yamlTags('hems')" />
</template>
</DeviceCard>
</ul>
</div>
</div>
<hr class="my-5" />
<h2 class="my-4 mt-5">{{ $t("config.section.system") }}</h2>
<div class="round-box p-4 d-flex gap-4 mb-5">
<router-link to="/log" class="btn btn-outline-secondary">
{{ $t("config.system.logs") }}
</router-link>
<button class="btn btn-outline-danger" @click="restart">
{{ $t("config.system.restart") }}
</button>
</div>
<VehicleModal :id="selectedVehicleId" @vehicle-changed="vehicleChanged" />
<MeterModal
@ -242,6 +267,17 @@
@updated="meterChanged"
@removed="removeMeterFromSite"
/>
<InfluxModal @changed="loadDirty" />
<MqttModal @changed="loadDirty" />
<NetworkModal @changed="loadDirty" />
<ControlModal @changed="loadDirty" />
<SponsorModal @changed="loadDirty" />
<HemsModal @changed="yamlChanged" />
<MessagingModal @changed="yamlChanged" />
<TariffsModal @changed="yamlChanged" />
<ModbusProxyModal @changed="yamlChanged" />
<CircuitsModal @changed="yamlChanged" />
<EebusModal @changed="yamlChanged" />
</div>
</div>
</div>
@ -252,36 +288,69 @@ 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/money";
import "@h2d2/shopicons/es/regular/receivepayment";
import "@h2d2/shopicons/es/regular/eco1";
import "@h2d2/shopicons/es/regular/fastdelivery1";
import "@h2d2/shopicons/es/regular/sendit";
import "@h2d2/shopicons/es/regular/diagram";
import "@h2d2/shopicons/es/regular/polygon";
import "@h2d2/shopicons/es/regular/cablecharge";
import Modal from "bootstrap/js/dist/modal";
import AddDeviceButton from "../components/Config/AddDeviceButton.vue";
import api from "../api";
import VehicleIcon from "../components/VehicleIcon";
import VehicleModal from "../components/Config/VehicleModal.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 DeviceCard from "../components/Config/DeviceCard.vue";
import DeviceTags from "../components/Config/DeviceTags.vue";
import AddDeviceButton from "../components/Config/AddDeviceButton.vue";
import MeterModal from "../components/Config/MeterModal.vue";
import GeneralConfig from "../components/Config/GeneralConfig.vue";
import EebusIcon from "../components/MaterialIcon/Eebus.vue";
import EebusModal from "../components/Config/EebusModal.vue";
import formatter from "../mixins/formatter";
import GeneralConfig from "../components/Config/GeneralConfig.vue";
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 MessagingModal from "../components/Config/MessagingModal.vue";
import MeterModal from "../components/Config/MeterModal.vue";
import Modal from "bootstrap/js/dist/modal";
import ModbusProxyIcon from "../components/MaterialIcon/ModbusProxy.vue";
import ModbusProxyModal from "../components/Config/ModbusProxyModal.vue";
import MqttIcon from "../components/MaterialIcon/Mqtt.vue";
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 TariffsModal from "../components/Config/TariffsModal.vue";
import VehicleIcon from "../components/VehicleIcon";
import VehicleModal from "../components/Config/VehicleModal.vue";
export default {
name: "Config",
components: {
TopHeader,
GeneralConfig,
VehicleIcon,
VehicleModal,
AddDeviceButton,
CircuitsIcon,
CircuitsModal,
ControlModal,
DeviceCard,
DeviceTags,
AddDeviceButton,
EebusIcon,
EebusModal,
GeneralConfig,
HemsIcon,
HemsModal,
InfluxIcon,
InfluxModal,
MessagingModal,
MeterModal,
ModbusProxyIcon,
ModbusProxyModal,
MqttIcon,
MqttModal,
NetworkModal,
NotificationIcon,
SponsorModal,
TariffsModal,
TopHeader,
VehicleIcon,
VehicleModal,
},
props: {
offline: Boolean,
@ -289,8 +358,6 @@ export default {
},
data() {
return {
dirty: false,
restarting: false,
vehicles: [],
meters: [],
selectedVehicleId: undefined,
@ -299,10 +366,20 @@ export default {
site: { grid: "", pv: [], battery: [] },
deviceValueTimeout: undefined,
deviceValues: {},
yamlConfigState: {
messaging: false,
eebus: false,
circuits: false,
modbusproxy: false,
hems: false,
},
};
},
mixins: [formatter],
mixins: [formatter, collector],
computed: {
fatalClass() {
return store.state?.fatal?.class;
},
siteTitle() {
return this.site?.title;
},
@ -321,11 +398,43 @@ export default {
selectedMeterName() {
return this.getMeterById(this.selectedMeterId)?.name;
},
tariffTags() {
const { currency, tariffGrid, tariffFeedIn, tariffCo2 } = store.state;
const tags = {};
if (currency) {
tags.currency = { value: currency };
}
if (tariffGrid) {
tags.gridPrice = { value: tariffGrid, options: { currency } };
}
if (tariffFeedIn) {
tags.feedinPrice = { value: tariffFeedIn * -1, options: { currency } };
}
if (tariffCo2) {
tags.co2 = { value: tariffCo2 };
}
return tags;
},
mqttTags() {
const { broker, topic } = store.state?.mqtt || {};
if (!broker) return { configured: { value: false } };
return {
broker: { value: broker },
topic: { value: topic },
};
},
influxTags() {
const { url, database, org } = store.state?.influx || {};
if (!url) return { configured: { value: false } };
const result = { url: { value: url } };
if (database) result.bucket = { value: database };
if (org) result.org = { value: org };
return result;
},
},
watch: {
offline() {
if (!this.offline) {
this.restarting = false;
this.loadAll();
}
},
@ -343,13 +452,12 @@ export default {
await this.loadSite();
await this.loadDirty();
await this.updateValues();
await this.updateYamlConfigState();
},
async loadDirty() {
try {
const response = await api.get("/config/dirty");
this.dirty = response.data?.result;
} catch (e) {
console.error(e);
if (response.data?.result) {
restart.restartNeeded = true;
}
},
async loadVehicles() {
@ -361,8 +469,12 @@ export default {
this.meters = response.data?.result || [];
},
async loadSite() {
const response = await api.get("/config/site");
const response = await api.get("/config/site", {
validateStatus: (status) => status < 500,
});
if (response.status === 200) {
this.site = response.data?.result;
}
},
getMetersByNames(names) {
if (!names || !this.meters) {
@ -417,6 +529,10 @@ export default {
siteChanged() {
this.loadDirty();
},
yamlChanged() {
this.loadDirty();
this.updateYamlConfigState();
},
addMeterToSite(type, name) {
if (type === "grid") {
this.site.grid = name;
@ -447,12 +563,7 @@ export default {
alert("not implemented yet");
},
async restart() {
try {
await api.post("/system/shutdown");
this.restarting = true;
} catch (e) {
alert("Unabled to restart server.");
}
await performRestart();
},
async updateDeviceValue(type, name) {
try {
@ -489,6 +600,20 @@ export default {
console.error(`modal ${id} not found`);
}
},
updateYamlConfigState() {
const keys = Object.keys(this.yamlConfigState);
keys.forEach(async (key) => {
const res = await api.get(`/config/${key}`);
this.yamlConfigState[key] = !!res.data.result;
});
},
yamlTags(key) {
return { configured: { value: this.yamlConfigState[key] } };
},
deviceError(type, name) {
const fatal = store.state?.fatal || {};
return fatal.class === type && fatal.device === name;
},
},
};
</script>
@ -496,7 +621,7 @@ export default {
.config-list {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
grid-gap: 1rem;
grid-gap: 2rem;
margin-bottom: 5rem;
}
.wip {

View file

@ -1,154 +0,0 @@
<template>
<div class="container px-4 safe-area-inset">
<div class="d-flex justify-content-between align-items-center my-3 my-md-4">
<h1 class="d-block mt-0 d-flex">
{{ $t("startupError.title") }}
<shopicon-regular-car1 size="m" class="ms-2 icon"></shopicon-regular-car1>
</h1>
</div>
<div class="row mb-4">
<code class="fs-6 mb-3">
<div v-for="(error, index) in errors" :key="index">{{ error }}</div>
</code>
<i18n-t tag="p" keypath="startupError.description">
<a href="https://github.com/evcc-io/evcc/discussions">
{{ $t("startupError.discussions") }}
</a>
</i18n-t>
<p>
<em>{{ $t("startupError.hint") }}</em>
</p>
</div>
<div class="row mb-4">
<h5 class="mb-3">{{ $t("startupError.configuration") }}</h5>
<div class="d-md-flex justify-content-between">
<p class="me-md-4">
<span class="d-block">
{{ $t("startupError.configFile") }}
<code>{{ file }}</code>
</span>
<i18n-t v-if="line" tag="span" keypath="startupError.lineError">
<a :href="`#line${line}`" @click.prevent="scrollTo">{{
$t("startupError.lineErrorLink", [line])
}}</a>
</i18n-t>
{{ $t("startupError.fixAndRestart") }}
</p>
<p>
<button
class="btn btn-primary text-nowrap"
type="button"
:disabled="offline"
@click="shutdown"
>
<span
v-if="offline"
class="spinner-border spinner-border-sm"
role="status"
aria-hidden="true"
></span>
{{ $t("startupError.restartButton") }}
</button>
</p>
</div>
<code v-if="config">
<div class="my-2"></div>
<div class="py-2 text-muted config">
<div
v-for="(configLine, lineNumber) in config.split('\n')"
:id="`line${lineNumber + 1}`"
:key="lineNumber"
class="m-0 px-2"
:class="{
highlighted: line === lineNumber + 1,
}"
>
{{ configLine }}&nbsp;
</div>
</div>
</code>
</div>
</div>
</template>
<script>
import "@h2d2/shopicons/es/regular/car1";
import api from "../api";
import collector from "../mixins/collector";
import store from "../store";
export default {
name: "StartupError",
mixins: [collector],
props: {
offline: Boolean,
},
computed: {
errors() {
return store.state.fatal || [];
},
config() {
return store.state.config;
},
file() {
return store.state.file;
},
line() {
return store.state.line;
},
},
methods: {
shutdown() {
api.post("/system/shutdown");
},
scrollTo(e) {
const id = e.currentTarget.getAttribute("href").substring(1);
const el = document.getElementById(id);
if (el) {
el.scrollIntoView({ behavior: "smooth", block: "center" });
}
},
},
};
</script>
<style scoped>
.highlighted {
position: relative;
color: var(--bs-code-color) !important;
}
.highlighted:before {
content: "";
position: absolute;
left: 0;
top: 0.2em;
border-top: 0.5em solid transparent;
border-bottom: 0.5em solid transparent;
border-left: 0.5em solid var(--bs-code-color);
}
.config {
max-width: 100%;
overflow-x: scroll;
white-space: pre;
}
.config {
border: 1px solid var(--bs-gray-400);
}
.icon {
transform-origin: 60% 40%;
animation: swinging 3.5s ease-in-out infinite;
}
@keyframes swinging {
0% {
transform: translateY(8%) rotate(170deg);
}
50% {
transform: translateY(8%) rotate(185deg);
}
100% {
transform: translateY(8%) rotate(170deg);
}
}
</style>

View file

@ -14,6 +14,7 @@ import (
"strings"
"sync"
"dario.cat/mergo"
"github.com/enbility/cemd/cem"
"github.com/enbility/cemd/emobility"
"github.com/enbility/eebus-go/service"
@ -28,6 +29,15 @@ const (
EEBUSDeviceCode string = "EVCC_HEMS_01" // used as common name in cert generation
)
type Config struct {
URI string
ShipID string
Interfaces []string
Certificate struct {
Public, Private []byte
}
}
type EEBusClientCBs struct {
onConnect func(string) // , ship.Conn) error
onDisconnect func(string)
@ -46,25 +56,17 @@ type EEBus struct {
var Instance *EEBus
func NewServer(other map[string]interface{}) (*EEBus, error) {
cc := struct {
Uri string
ShipID string
Interfaces []string
Certificate struct {
Public, Private []byte
}
}{
Uri: ":4712",
func NewServer(other Config) (*EEBus, error) {
cc := Config{
URI: ":4712",
}
if err := util.DecodeOther(other, &cc); err != nil {
if err := mergo.Merge(&cc, other, mergo.WithOverride); err != nil {
return nil, err
}
log := util.NewLogger("eebus")
var err error
protectedID, err := machine.ProtectedID("evcc-eebus")
if err != nil {
return nil, err
@ -80,7 +82,7 @@ func NewServer(other map[string]interface{}) (*EEBus, error) {
return nil, err
}
_, portValue, err := net.SplitHostPort(cc.Uri)
_, portValue, err := net.SplitHostPort(cc.URI)
if err != nil {
return nil, err
}

View file

@ -36,7 +36,7 @@ func runCharger(cmd *cobra.Command, args []string) {
}
// setup environment
if err := configureEnvironment(cmd, conf); err != nil {
if err := configureEnvironment(cmd, &conf); err != nil {
log.FATAL.Fatal(err)
}

View file

@ -71,7 +71,7 @@ func runChargerRamp(cmd *cobra.Command, args []string) {
}
// setup environment
if err := configureEnvironment(cmd, conf); err != nil {
if err := configureEnvironment(cmd, &conf); err != nil {
log.FATAL.Fatal(err)
}

147
cmd/class_enumer.go Normal file
View file

@ -0,0 +1,147 @@
// Code generated by "enumer -type Class -trimprefix Class -transform=lower -text"; DO NOT EDIT.
package cmd
import (
"fmt"
"strings"
)
const _ClassName = "meterchargervehicletariffcircuitsitemqttdatabasemodbusproxyeebusjavascriptgohemsinfluxmessengersponsorship"
var _ClassIndex = [...]uint8{0, 5, 12, 19, 25, 32, 36, 40, 48, 59, 64, 74, 76, 80, 86, 95, 106}
const _ClassLowerName = "meterchargervehicletariffcircuitsitemqttdatabasemodbusproxyeebusjavascriptgohemsinfluxmessengersponsorship"
func (i Class) String() string {
i -= 1
if i < 0 || i >= Class(len(_ClassIndex)-1) {
return fmt.Sprintf("Class(%d)", i+1)
}
return _ClassName[_ClassIndex[i]:_ClassIndex[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 _ClassNoOp() {
var x [1]struct{}
_ = x[ClassMeter-(1)]
_ = x[ClassCharger-(2)]
_ = x[ClassVehicle-(3)]
_ = x[ClassTariff-(4)]
_ = x[ClassCircuit-(5)]
_ = x[ClassSite-(6)]
_ = x[ClassMqtt-(7)]
_ = x[ClassDatabase-(8)]
_ = x[ClassModbusProxy-(9)]
_ = x[ClassEEBus-(10)]
_ = x[ClassJavascript-(11)]
_ = x[ClassGo-(12)]
_ = x[ClassHEMS-(13)]
_ = x[ClassInflux-(14)]
_ = x[ClassMessenger-(15)]
_ = x[ClassSponsorship-(16)]
}
var _ClassValues = []Class{ClassMeter, ClassCharger, ClassVehicle, ClassTariff, ClassCircuit, ClassSite, ClassMqtt, ClassDatabase, ClassModbusProxy, ClassEEBus, ClassJavascript, ClassGo, ClassHEMS, ClassInflux, ClassMessenger, ClassSponsorship}
var _ClassNameToValueMap = map[string]Class{
_ClassName[0:5]: ClassMeter,
_ClassLowerName[0:5]: ClassMeter,
_ClassName[5:12]: ClassCharger,
_ClassLowerName[5:12]: ClassCharger,
_ClassName[12:19]: ClassVehicle,
_ClassLowerName[12:19]: ClassVehicle,
_ClassName[19:25]: ClassTariff,
_ClassLowerName[19:25]: ClassTariff,
_ClassName[25:32]: ClassCircuit,
_ClassLowerName[25:32]: ClassCircuit,
_ClassName[32:36]: ClassSite,
_ClassLowerName[32:36]: ClassSite,
_ClassName[36:40]: ClassMqtt,
_ClassLowerName[36:40]: ClassMqtt,
_ClassName[40:48]: ClassDatabase,
_ClassLowerName[40:48]: ClassDatabase,
_ClassName[48:59]: ClassModbusProxy,
_ClassLowerName[48:59]: ClassModbusProxy,
_ClassName[59:64]: ClassEEBus,
_ClassLowerName[59:64]: ClassEEBus,
_ClassName[64:74]: ClassJavascript,
_ClassLowerName[64:74]: ClassJavascript,
_ClassName[74:76]: ClassGo,
_ClassLowerName[74:76]: ClassGo,
_ClassName[76:80]: ClassHEMS,
_ClassLowerName[76:80]: ClassHEMS,
_ClassName[80:86]: ClassInflux,
_ClassLowerName[80:86]: ClassInflux,
_ClassName[86:95]: ClassMessenger,
_ClassLowerName[86:95]: ClassMessenger,
_ClassName[95:106]: ClassSponsorship,
_ClassLowerName[95:106]: ClassSponsorship,
}
var _ClassNames = []string{
_ClassName[0:5],
_ClassName[5:12],
_ClassName[12:19],
_ClassName[19:25],
_ClassName[25:32],
_ClassName[32:36],
_ClassName[36:40],
_ClassName[40:48],
_ClassName[48:59],
_ClassName[59:64],
_ClassName[64:74],
_ClassName[74:76],
_ClassName[76:80],
_ClassName[80:86],
_ClassName[86:95],
_ClassName[95:106],
}
// ClassString retrieves an enum value from the enum constants string name.
// Throws an error if the param is not part of the enum.
func ClassString(s string) (Class, error) {
if val, ok := _ClassNameToValueMap[s]; ok {
return val, nil
}
if val, ok := _ClassNameToValueMap[strings.ToLower(s)]; ok {
return val, nil
}
return 0, fmt.Errorf("%s does not belong to Class values", s)
}
// ClassValues returns all values of the enum
func ClassValues() []Class {
return _ClassValues
}
// ClassStrings returns a slice of all String values of the enum
func ClassStrings() []string {
strs := make([]string, len(_ClassNames))
copy(strs, _ClassNames)
return strs
}
// IsAClass returns "true" if the value is listed in the enum definition. "false" otherwise
func (i Class) IsAClass() bool {
for _, v := range _ClassValues {
if i == v {
return true
}
}
return false
}
// MarshalText implements the encoding.TextMarshaler interface for Class
func (i Class) MarshalText() ([]byte, error) {
return []byte(i.String()), nil
}
// UnmarshalText implements the encoding.TextUnmarshaler interface for Class
func (i *Class) UnmarshalText(text []byte) error {
var err error
*i, err = ClassString(string(text))
return err
}

View file

@ -5,12 +5,22 @@ import (
"github.com/evcc-io/evcc/charger/eebus"
"github.com/evcc-io/evcc/cmd/shutdown"
"github.com/evcc-io/evcc/util"
)
// configureEEBus setup EEBus
func (c *CmdConfigure) configureEEBus(conf map[string]interface{}) error {
var err error
if eebus.Instance, err = eebus.NewServer(conf); err == nil {
func (c *CmdConfigure) configureEEBus(other map[string]interface{}) error {
conf := eebus.Config{
URI: ":4712",
}
if err := util.DecodeOther(other, &conf); err != nil {
return err
}
srv, err := eebus.NewServer(conf)
if err == nil {
eebus.Instance = srv
go eebus.Instance.Run()
shutdown.Register(eebus.Instance.Shutdown)
}

View file

@ -4,12 +4,14 @@ import (
_ "embed" // for yaml
"fmt"
"strings"
"github.com/evcc-io/evcc/api/globalconfig"
)
//go:embed demo.yaml
var demoYaml string
func demoConfig(conf *globalConfig) error {
func demoConfig(conf *globalconfig.All) error {
viper.SetConfigType("yaml")
if err := viper.ReadConfig(strings.NewReader(demoYaml)); err != nil {
return fmt.Errorf("failed decoding demo config: %w", err)

View file

@ -29,7 +29,7 @@ func runDevice(cmd *cobra.Command, args []string) {
}
// setup environment
if err := configureEnvironment(cmd, conf); err != nil {
if err := configureEnvironment(cmd, &conf); err != nil {
log.FATAL.Fatal(err)
}

View file

@ -49,12 +49,12 @@ func runDump(cmd *cobra.Command, args []string) {
// setup environment
if err == nil {
err = configureEnvironment(cmd, conf)
err = configureEnvironment(cmd, &conf)
}
var site *core.Site
if err == nil {
site, err = configureSiteAndLoadpoints(conf)
site, err = configureSiteAndLoadpoints(&conf)
}
if *dumpConfig {

78
cmd/error.go Normal file
View file

@ -0,0 +1,78 @@
package cmd
import (
"encoding/json"
"errors"
)
type Class int
//go:generate enumer -type Class -trimprefix Class -transform=lower -text
const (
_ Class = iota
ClassMeter
ClassCharger
ClassVehicle
ClassTariff
ClassCircuit
ClassSite
ClassMqtt
ClassDatabase
ClassModbusProxy
ClassEEBus
ClassJavascript
ClassGo
ClassHEMS
ClassInflux
ClassMessenger
ClassSponsorship
)
// DeviceError indicates the specific device that failed
type DeviceError struct {
Name string
err error
}
func (e *DeviceError) Error() string {
return e.err.Error()
}
// ClassError indicates the class of devices that failed
type ClassError struct {
Class Class
err error
}
func (e *ClassError) Error() string {
return e.err.Error()
}
func (e ClassError) MarshalJSON() ([]byte, error) {
res := struct {
Class string `json:"class"`
Device string `json:"device,omitempty"`
Error string `json:"error"`
}{
Class: e.Class.String(),
Error: e.err.Error(),
}
var de *DeviceError
if errors.As(e.err, &de) {
res.Device = de.Name
}
return json.Marshal(res)
}
func wrapErrorWithClass(class Class, err error) error {
if err == nil {
return nil
}
return &ClassError{
Class: class,
err: err,
}
}

22
cmd/error_test.go Normal file
View file

@ -0,0 +1,22 @@
package cmd
import (
"errors"
"testing"
"github.com/stretchr/testify/require"
)
func TestError(t *testing.T) {
res := &ClassError{
ClassMeter,
&DeviceError{
"0815",
errors.New("foo"),
},
}
b, err := res.MarshalJSON()
require.NoError(t, err)
require.Equal(t, `{"class":"meter","device":"0815","error":"foo"}`, string(b))
}

View file

@ -25,6 +25,9 @@ const (
flagCloud = "cloud"
flagCloudDescription = "Use cloud service (requires sponsor token)"
flagReset = "reset"
flagResetDescription = "Reset migrated settings"
flagEnable = "enable"
flagDisable = "disable"

View file

@ -5,9 +5,7 @@ import (
"fmt"
"net"
"os"
"path/filepath"
"regexp"
"strconv"
"strings"
"github.com/evcc-io/evcc/cmd/shutdown"
@ -63,31 +61,6 @@ func redact(src string) string {
ReplaceAllString(src, "$1: *****")
}
func publishErrorInfo(valueChan chan<- util.Param, cfgFile string, err error) {
if cfgFile != "" {
file, pathErr := filepath.Abs(cfgFile)
if pathErr != nil {
file = cfgFile
}
valueChan <- util.Param{Key: "file", Val: file}
if src, fileErr := os.ReadFile(cfgFile); fileErr != nil {
log.ERROR.Println("could not open config file:", fileErr)
} else {
valueChan <- util.Param{Key: "config", Val: redact(string(src))}
// find line number
if match := regexp.MustCompile(`yaml: line (\d+):`).FindStringSubmatch(err.Error()); len(match) == 2 {
if line, err := strconv.Atoi(match[1]); err == nil {
valueChan <- util.Param{Key: "line", Val: line}
}
}
}
}
valueChan <- util.Param{Key: "fatal", Val: unwrap(err)}
}
// fatal logs a fatal error and runs shutdown functions before terminating
func fatal(err error) {
log.FATAL.Println(err)
@ -102,7 +75,7 @@ func shutdownDoneC() <-chan struct{} {
return doneC
}
func wrapErrors(err error) error {
func wrapError(err error) error {
if err != nil {
var opErr *net.OpError
var pathErr *os.PathError

View file

@ -30,7 +30,7 @@ func runMeter(cmd *cobra.Command, args []string) {
}
// setup environment
if err := configureEnvironment(cmd, conf); err != nil {
if err := configureEnvironment(cmd, &conf); err != nil {
log.FATAL.Fatal(err)
}

116
cmd/migrate.go Normal file
View file

@ -0,0 +1,116 @@
package cmd
import (
"github.com/evcc-io/evcc/core/keys"
"github.com/evcc-io/evcc/server/db/settings"
"github.com/spf13/cobra"
)
// migrateCmd represents the migrate command
var migrateCmd = &cobra.Command{
Use: "migrate",
Short: "Migrate yaml to database (overwrites db settings)",
Args: cobra.ExactArgs(0),
Run: runMigrate,
}
func init() {
rootCmd.AddCommand(migrateCmd)
migrateCmd.Flags().BoolP(flagReset, "r", false, flagResetDescription)
}
func runMigrate(cmd *cobra.Command, args []string) {
// load config
if err := loadConfigFile(&conf, !cmd.Flag(flagIgnoreDatabase).Changed); err != nil {
log.FATAL.Fatal(err)
}
// setup persistence
if err := configureDatabase(conf.Database); err != nil {
log.FATAL.Fatal(err)
}
reset := cmd.Flags().Lookup(flagReset).Changed
// TODO remove yaml file
if reset {
log.WARN.Println("resetting:")
} else {
log.WARN.Println("migrating:")
}
log.DEBUG.Println("- global settings")
if reset {
settings.Delete(keys.Interval)
settings.Delete(keys.SponsorToken)
} else {
settings.SetInt(keys.Interval, int64(conf.Interval))
settings.SetString(keys.SponsorToken, conf.SponsorToken)
}
log.DEBUG.Println("- network")
if reset {
settings.Delete(keys.Network)
} else {
_ = settings.SetJson(keys.Network, conf.Network)
}
log.DEBUG.Println("- mqtt")
if reset {
settings.Delete(keys.Mqtt)
} else {
_ = settings.SetJson(keys.Mqtt, conf.Mqtt)
}
log.DEBUG.Println("- influx")
if reset {
settings.Delete(keys.Influx)
} else {
_ = settings.SetJson(keys.Influx, conf.Influx)
}
log.DEBUG.Println("- hems")
if reset {
settings.Delete(keys.Hems)
} else if conf.HEMS.Type != "" {
_ = settings.SetYaml(keys.Hems, conf.HEMS)
}
log.DEBUG.Println("- eebus")
if reset {
settings.Delete(keys.EEBus)
} else if conf.EEBus.URI != "" {
_ = settings.SetYaml(keys.EEBus, conf.EEBus)
}
log.DEBUG.Println("- modbusproxy")
if reset {
settings.Delete(keys.ModbusProxy)
} else if len(conf.ModbusProxy) > 0 {
_ = settings.SetYaml(keys.ModbusProxy, conf.ModbusProxy)
}
log.DEBUG.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")
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")
if reset {
settings.Delete(keys.Circuits)
} else if len(conf.Circuits) > 0 {
_ = settings.SetYaml(keys.Circuits, conf.Circuits)
}
// wait for shutdown
<-shutdownDoneC()
}

View file

@ -24,7 +24,7 @@ func runPasswordReset(cmd *cobra.Command, args []string) {
}
// setup environment
if err := configureEnvironment(cmd, conf); err != nil {
if err := configureEnvironment(cmd, &conf); err != nil {
log.FATAL.Fatal(err)
}

View file

@ -24,7 +24,7 @@ func runPasswordSet(cmd *cobra.Command, args []string) {
}
// setup environment
if err := configureEnvironment(cmd, conf); err != nil {
if err := configureEnvironment(cmd, &conf); err != nil {
log.FATAL.Fatal(err)
}

View file

@ -13,24 +13,24 @@ import (
"time"
"github.com/evcc-io/evcc/core"
"github.com/evcc-io/evcc/core/keys"
"github.com/evcc-io/evcc/push"
"github.com/evcc-io/evcc/server"
"github.com/evcc-io/evcc/server/modbus"
"github.com/evcc-io/evcc/server/updater"
"github.com/evcc-io/evcc/util"
"github.com/evcc-io/evcc/util/auth"
"github.com/evcc-io/evcc/util/config"
"github.com/evcc-io/evcc/util/pipe"
"github.com/evcc-io/evcc/util/sponsor"
"github.com/evcc-io/evcc/util/telemetry"
_ "github.com/joho/godotenv/autoload"
"github.com/prometheus/client_golang/prometheus/promhttp"
"github.com/spf13/cast"
"github.com/spf13/cobra"
vpr "github.com/spf13/viper"
)
const (
rebootDelay = 5 * time.Minute // delayed reboot on error
rebootDelay = 15 * time.Minute // delayed reboot on error
serviceDB = "/var/lib/evcc/evcc.db"
)
@ -122,15 +122,22 @@ func runRoot(cmd *cobra.Command, args []string) {
err = cfgErr
}
// network config
if viper.GetString("uri") != "" {
log.WARN.Println("`uri` is deprecated and will be ignored. Use `network` instead.")
// setup environment
if err == nil {
err = configureEnvironment(cmd, &conf)
}
log.INFO.Printf("starting ui and api at :%d", conf.Network.Port)
// configure network
if err == nil {
err = networkSettings(&conf.Network)
}
log.INFO.Printf("listening at :%d", conf.Network.Port)
// start broadcasting values
tee := new(util.Tee)
valueChan := make(chan util.Param, 64)
go tee.Run(valueChan)
// value cache
cache := util.NewCache()
@ -153,18 +160,9 @@ func runRoot(cmd *cobra.Command, args []string) {
// publish to UI
go socketHub.Run(pipe.NewDropper(ignoreEmpty).Pipe(tee.Attach()), cache)
// setup values channel
valueChan := make(chan util.Param, 64)
go tee.Run(valueChan)
// capture log messages for UI
util.CaptureLogs(valueChan)
// setup environment
if err == nil {
err = configureEnvironment(cmd, conf)
}
// setup telemetry
if err == nil {
telemetry.Create(conf.Plant)
@ -175,30 +173,43 @@ func runRoot(cmd *cobra.Command, args []string) {
// setup modbus proxy
if err == nil {
for _, cfg := range conf.ModbusProxy {
var mode modbus.ReadOnlyMode
mode, err = modbus.ReadOnlyModeString(cfg.ReadOnly)
if err != nil {
break
}
if err = modbus.StartProxy(cfg.Port, cfg.Settings, mode); err != nil {
break
}
}
err = wrapErrorWithClass(ClassModbusProxy, configureModbusProxy(conf.ModbusProxy))
}
// setup site and loadpoints
var site *core.Site
if err == nil {
site, err = configureSiteAndLoadpoints(conf)
site, err = configureSiteAndLoadpoints(&conf)
}
// setup database
if err == nil && conf.Influx.URL != "" {
configureInflux(conf.Influx, site, pipe.NewDropper(append(ignoreLogs, ignoreEmpty)...).Pipe(tee.Attach()))
// setup influx
if err == nil {
influx, ierr := configureInflux(&conf.Influx)
if ierr != nil {
err = wrapErrorWithClass(ClassInflux, ierr)
}
if err == nil && influx != nil {
// eliminate duplicate values
dedupe := pipe.NewDeduplicator(30*time.Minute,
keys.VehicleSoc, keys.VehicleRange, keys.VehicleOdometer,
keys.ChargedEnergy, keys.ChargeRemainingEnergy)
go influx.Run(site, dedupe.Pipe(
pipe.NewDropper(append(ignoreLogs, ignoreEmpty)...).Pipe(tee.Attach()),
))
}
}
// remove previous fatal startup errors
valueChan <- util.Param{Key: keys.Fatal, Val: nil}
// publish initial settings
valueChan <- util.Param{Key: keys.Interval, Val: conf.Interval}
valueChan <- util.Param{Key: keys.Network, Val: conf.Network}
valueChan <- util.Param{Key: keys.Mqtt, Val: conf.Mqtt}
valueChan <- util.Param{Key: keys.Influx, Val: conf.Influx}
// TODO
valueChan <- util.Param{Key: keys.Sponsor, Val: sponsor.Status()}
// setup mqtt publisher
if err == nil && conf.Mqtt.Broker != "" {
var mqtt *server.MQTT
@ -214,14 +225,15 @@ func runRoot(cmd *cobra.Command, args []string) {
}
// start HEMS server
if err == nil && conf.HEMS.Type != "" {
err = configureHEMS(conf.HEMS, site, httpd)
if err == nil {
err = wrapErrorWithClass(ClassHEMS, configureHEMS(conf.HEMS, site, httpd))
}
// setup messaging
var pushChan chan push.Event
if err == nil {
pushChan, err = configureMessengers(conf.Messaging, site.Vehicles(), valueChan, cache)
err = wrapErrorWithClass(ClassMessenger, err)
}
// run shutdown functions on stop
@ -246,65 +258,43 @@ func runRoot(cmd *cobra.Command, args []string) {
case <-time.After(conf.Interval):
}
if err != nil {
os.Exit(1)
}
os.Exit(0)
// exit code 1 on error
os.Exit(cast.ToInt(err != nil))
}()
auth := auth.New()
// show main ui
if err == nil {
httpd.RegisterSiteHandlers(site, auth, cache)
httpd.RegisterAuthHandlers(auth)
httpd.RegisterSystemHandler(auth, func() {
log.INFO.Println("evcc was stopped by user. OS should restart the service. Or restart manually.")
once.Do(func() { close(stopC) }) // signal loop to end
})
// set channels
site.DumpConfig()
site.Prepare(valueChan, pushChan)
// show and check version, reduce api load during development
if server.Version != server.DevVersion {
valueChan <- util.Param{Key: "version", Val: server.FormattedVersion()}
go updater.Run(log, httpd, valueChan)
}
// remove previous fatal startup errors
valueChan <- util.Param{Key: "fatal", Val: nil}
// expose sponsor to UI
if sponsor.Subject != "" {
valueChan <- util.Param{Key: "sponsor", Val: sponsor.Subject}
var validDuration time.Duration
if d := time.Until(sponsor.ExpiresAt); d > 0 && d < 30*24*time.Hour {
validDuration = d
}
valueChan <- util.Param{Key: "sponsorTokenExpires", Val: validDuration}
}
// allow web access for vehicles
configureAuth(conf.Network, config.Instances(config.Vehicles().Devices()), httpd.Router(), valueChan)
go func() {
site.Run(stopC, conf.Interval)
}()
} else {
httpd.RegisterAuthHandlers(auth)
httpd.RegisterSystemHandler(auth, func() {
httpd.RegisterSystemHandler(valueChan, cache, func() {
log.INFO.Println("evcc was stopped by user. OS should restart the service. Or restart manually.")
once.Do(func() { close(stopC) }) // signal loop to end
})
// show and check version, reduce api load during development
if server.Version != server.DevVersion {
valueChan <- util.Param{Key: keys.Version, Val: server.FormattedVersion()}
go updater.Run(log, httpd, valueChan)
}
// setup site
if err == nil {
// set channels
site.DumpConfig()
site.Prepare(valueChan, pushChan)
httpd.RegisterSiteHandlers(site, valueChan)
go func() {
site.Run(stopC, conf.Interval)
}()
}
if err != nil {
// improve error message
err = wrapErrors(err)
publishErrorInfo(valueChan, cfgFile, err)
err = wrapError(err)
valueChan <- util.Param{Key: keys.Fatal, Val: err}
// TODO stop reboot loop if user updates config (or show countdown in UI)
log.FATAL.Println(err)
log.FATAL.Printf("will attempt restart in: %v", rebootDelay)
@ -317,5 +307,5 @@ func runRoot(cmd *cobra.Command, args []string) {
// uds health check listener
go server.HealthListener(site)
log.FATAL.Println(wrapErrors(httpd.ListenAndServe()))
log.FATAL.Println(wrapError(httpd.ListenAndServe()))
}

View file

@ -28,8 +28,8 @@ func runSettingsGet(cmd *cobra.Command, args []string) {
log.FATAL.Fatal(err)
}
// setup environment
if err := configureEnvironment(cmd, conf); err != nil {
// setup persistence
if err := configureDatabase(conf.Database); err != nil {
log.FATAL.Fatal(err)
}

View file

@ -27,8 +27,8 @@ func runSettingsSet(cmd *cobra.Command, args []string) {
log.FATAL.Fatal(err)
}
// setup environment
if err := configureEnvironment(cmd, conf); err != nil {
// setup persistence
if err := configureDatabase(conf.Database); err != nil {
log.FATAL.Fatal(err)
}

View file

@ -5,7 +5,6 @@ import (
"context"
"errors"
"fmt"
"net"
"net/http"
"os"
"regexp"
@ -17,10 +16,12 @@ import (
paho "github.com/eclipse/paho.mqtt.golang"
"github.com/evcc-io/evcc/api"
"github.com/evcc-io/evcc/api/globalconfig"
"github.com/evcc-io/evcc/charger"
"github.com/evcc-io/evcc/charger/eebus"
"github.com/evcc-io/evcc/cmd/shutdown"
"github.com/evcc-io/evcc/core"
"github.com/evcc-io/evcc/core/keys"
"github.com/evcc-io/evcc/core/site"
"github.com/evcc-io/evcc/hems"
"github.com/evcc-io/evcc/meter"
@ -31,14 +32,13 @@ import (
"github.com/evcc-io/evcc/server"
"github.com/evcc-io/evcc/server/db"
"github.com/evcc-io/evcc/server/db/settings"
"github.com/evcc-io/evcc/server/modbus"
"github.com/evcc-io/evcc/server/oauth2redirect"
"github.com/evcc-io/evcc/tariff"
"github.com/evcc-io/evcc/util"
"github.com/evcc-io/evcc/util/config"
"github.com/evcc-io/evcc/util/locale"
"github.com/evcc-io/evcc/util/machine"
"github.com/evcc-io/evcc/util/modbus"
"github.com/evcc-io/evcc/util/pipe"
"github.com/evcc-io/evcc/util/request"
"github.com/evcc-io/evcc/util/sponsor"
"github.com/evcc-io/evcc/util/templates"
@ -52,18 +52,18 @@ import (
"golang.org/x/text/currency"
)
var conf = globalConfig{
var conf = globalconfig.All{
Interval: 10 * time.Second,
Log: "info",
Network: networkConfig{
Network: globalconfig.Network{
Schema: "http",
Host: "evcc.local",
Port: 7070,
},
Mqtt: mqttConfig{
Mqtt: globalconfig.Mqtt{
Topic: "evcc",
},
Database: dbConfig{
Database: globalconfig.DB{
Type: "sqlite",
Dsn: "~/.evcc/evcc.db",
},
@ -71,90 +71,6 @@ var conf = globalConfig{
var nameRE = regexp.MustCompile(`^[a-zA-Z0-9_.:-]+$`)
type globalConfig struct {
Network networkConfig
Log string
SponsorToken string
Plant string // telemetry plant id
Telemetry bool
Metrics bool
Profile bool
Levels map[string]string
Interval time.Duration
Database dbConfig
Mqtt mqttConfig
ModbusProxy []proxyConfig
Javascript []javascriptConfig
Go []goConfig
Influx server.InfluxConfig
EEBus map[string]interface{}
HEMS config.Typed
Messaging messagingConfig
Meters []config.Named
Chargers []config.Named
Vehicles []config.Named
Tariffs tariffConfig
Site map[string]interface{}
Loadpoints []map[string]interface{}
Circuits []config.Named
}
type mqttConfig struct {
mqtt.Config `mapstructure:",squash"`
Topic string
}
type javascriptConfig struct {
VM string
Script string
}
type goConfig struct {
VM string
Script string
}
type proxyConfig struct {
Port int
ReadOnly string
modbus.Settings `mapstructure:",squash"`
}
type dbConfig struct {
Type string
Dsn string
}
type messagingConfig struct {
Events map[string]push.EventTemplateConfig
Services []config.Typed
}
type tariffConfig struct {
Currency string
Grid config.Typed
FeedIn config.Typed
Co2 config.Typed
Planner config.Typed
}
type networkConfig struct {
Schema string
Host string
Port int
}
func (c networkConfig) HostPort() string {
if c.Schema == "http" && c.Port == 80 || c.Schema == "https" && c.Port == 443 {
return c.Host
}
return net.JoinHostPort(c.Host, strconv.Itoa(c.Port))
}
func (c networkConfig) URI() string {
return fmt.Sprintf("%s://%s", c.Schema, c.HostPort())
}
func nameValid(name string) error {
if !nameRE.MatchString(name) {
return fmt.Errorf("name must not contain special characters or spaces: %s", name)
@ -178,7 +94,7 @@ func tokenDanger(conf []config.Named) bool {
return false
}
func loadConfigFile(conf *globalConfig, checkDB bool) error {
func loadConfigFile(conf *globalconfig.All, checkDB bool) error {
err := viper.ReadInConfig()
if cfgFile = viper.ConfigFileUsed(); cfgFile == "" {
@ -339,11 +255,11 @@ func configureMeters(static []config.Named, names ...string) error {
instance, err := meter.NewFromConfig(cc.Type, cc.Other)
if err != nil {
return fmt.Errorf("cannot create meter '%s': %w", cc.Name, err)
return &DeviceError{cc.Name, fmt.Errorf("cannot create meter '%s': %w", cc.Name, err)}
}
if err := config.Meters().Add(config.NewStaticDevice(cc, instance)); err != nil {
return err
return &DeviceError{cc.Name, err}
}
}
@ -360,13 +276,15 @@ func configureMeters(static []config.Named, names ...string) error {
return nil
}
// TOTO add fake devices
instance, err := meter.NewFromConfig(cc.Type, cc.Other)
if err != nil {
return fmt.Errorf("cannot create meter '%s': %w", cc.Name, err)
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 {
return err
return &DeviceError{cc.Name, err}
}
}
@ -392,10 +310,14 @@ func configureChargers(static []config.Named, names ...string) error {
g.Go(func() error {
instance, err := charger.NewFromConfig(cc.Type, cc.Other)
if err != nil {
return fmt.Errorf("cannot create charger '%s': %w", cc.Name, err)
return &DeviceError{cc.Name, fmt.Errorf("cannot create charger '%s': %w", cc.Name, err)}
}
return config.Chargers().Add(config.NewStaticDevice(cc, instance))
if err := config.Chargers().Add(config.NewStaticDevice(cc, instance)); err != nil {
return &DeviceError{cc.Name, err}
}
return nil
})
}
@ -413,12 +335,18 @@ func configureChargers(static []config.Named, names ...string) error {
return nil
}
// TOTO add fake devices
instance, err := charger.NewFromConfig(cc.Type, cc.Other)
if err != nil {
return fmt.Errorf("cannot create charger '%s': %w", cc.Name, err)
}
return config.Chargers().Add(config.NewConfigurableDevice(conf, instance))
if err := config.Chargers().Add(config.NewConfigurableDevice(conf, instance)); err != nil {
return &DeviceError{cc.Name, err}
}
return nil
})
}
@ -538,7 +466,19 @@ func configureVehicles(static []config.Named, names ...string) error {
return nil
}
func configureEnvironment(cmd *cobra.Command, conf globalConfig) (err error) {
func configureSponsorship(token string) (err error) {
if settings.Exists(keys.SponsorToken) {
if token, err = settings.String(keys.SponsorToken); err != nil {
return err
}
}
// TODO migrate settings
return sponsor.ConfigureSponsorship(token)
}
func configureEnvironment(cmd *cobra.Command, conf *globalconfig.All) (err error) {
// full http request log
if cmd.Flags().Lookup(flagHeaders).Changed {
request.LogHeaders = true
@ -546,46 +486,49 @@ func configureEnvironment(cmd *cobra.Command, conf globalConfig) (err error) {
// setup machine id
if conf.Plant != "" {
// TODO decide wrapping
err = machine.CustomID(conf.Plant)
}
// setup sponsorship (allow env override)
if err == nil {
err = sponsor.ConfigureSponsorship(conf.SponsorToken)
err = wrapErrorWithClass(ClassSponsorship, configureSponsorship(conf.SponsorToken))
}
// setup translations
if err == nil {
// TODO decide wrapping
err = locale.Init()
}
// setup persistence
if err == nil && conf.Database.Dsn != "" {
err = configureDatabase(conf.Database)
err = wrapErrorWithClass(ClassDatabase, configureDatabase(conf.Database))
}
// setup mqtt client listener
if err == nil && conf.Mqtt.Broker != "" {
err = configureMQTT(conf.Mqtt)
if err == nil {
err = wrapErrorWithClass(ClassMqtt, configureMqtt(&conf.Mqtt))
}
// setup EEBus server
if err == nil {
err = wrapErrorWithClass(ClassEEBus, configureEEBus(conf.EEBus))
}
// setup javascript VMs
if err == nil {
err = configureJavascript(conf.Javascript)
err = wrapErrorWithClass(ClassJavascript, configureJavascript(conf.Javascript))
}
// setup go VMs
if err == nil {
err = configureGo(conf.Go)
}
// setup EEBus server
if err == nil && conf.EEBus != nil {
err = configureEEBus(conf.EEBus)
err = wrapErrorWithClass(ClassGo, configureGo(conf.Go))
}
// setup config database
if err == nil {
// TODO decide wrapping
err = config.Init(db.Instance)
}
@ -593,7 +536,7 @@ func configureEnvironment(cmd *cobra.Command, conf globalConfig) (err error) {
}
// configureDatabase configures session database
func configureDatabase(conf dbConfig) error {
func configureDatabase(conf globalconfig.DB) error {
if err := db.NewInstance(conf.Type, conf.Dsn); err != nil {
return err
}
@ -622,7 +565,26 @@ func configureDatabase(conf dbConfig) error {
}
// configureInflux configures influx database
func configureInflux(conf server.InfluxConfig, site site.API, in <-chan util.Param) {
func configureInflux(conf *globalconfig.Influx) (*server.Influx, error) {
// read settings
if settings.Exists(keys.Influx) {
if err := settings.Json(keys.Influx, &conf); err != nil {
return nil, err
}
}
if conf.URL == "" {
return nil, nil
}
// TODO remove yaml file
// // migrate settings
// if !settings.Exists(keys.Influx) {
// if err := settings.SetJson(keys.Influx, conf); err != nil {
// return nil, err
// }
// }
influx := server.NewInfluxClient(
conf.URL,
conf.Token,
@ -632,15 +594,29 @@ func configureInflux(conf server.InfluxConfig, site site.API, in <-chan util.Par
conf.Database,
)
// eliminate duplicate values
dedupe := pipe.NewDeduplicator(30*time.Minute, "vehicleCapacity", "vehicleSoc", "vehicleRange", "vehicleOdometer", "chargedEnergy", "chargeRemainingEnergy")
in = dedupe.Pipe(in)
go influx.Run(site, in)
return influx, nil
}
// setup mqtt
func configureMQTT(conf mqttConfig) error {
func configureMqtt(conf *globalconfig.Mqtt) error {
// migrate settings
if settings.Exists(keys.Mqtt) {
if err := settings.Json(keys.Mqtt, &conf); err != nil {
return err
}
// TODO remove yaml file
// } else {
// // migrate settings & write defaults
// if err := settings.SetJson(keys.Mqtt, conf); err != nil {
// return err
// }
}
if conf.Broker == "" {
return nil
}
log := util.NewLogger("mqtt")
instance, err := mqtt.RegisteredClient(log, conf.Broker, conf.User, conf.Password, conf.ClientID, 1, conf.Insecure, func(options *paho.ClientOptions) {
@ -662,7 +638,7 @@ func configureMQTT(conf mqttConfig) error {
}
// setup javascript
func configureJavascript(conf []javascriptConfig) error {
func configureJavascript(conf []globalconfig.Javascript) error {
for _, cc := range conf {
if _, err := javascript.RegisteredVM(cc.VM, cc.Script); err != nil {
return fmt.Errorf("failed configuring javascript: %w", err)
@ -672,7 +648,7 @@ func configureJavascript(conf []javascriptConfig) error {
}
// setup go
func configureGo(conf []goConfig) error {
func configureGo(conf []globalconfig.Go) error {
for _, cc := range conf {
if _, err := golang.RegisteredVM(cc.VM, cc.Script); err != nil {
return fmt.Errorf("failed configuring go: %w", err)
@ -683,6 +659,25 @@ func configureGo(conf []goConfig) error {
// setup HEMS
func configureHEMS(conf config.Typed, site *core.Site, httpd *server.HTTPd) error {
// migrate settings
if settings.Exists(keys.Hems) {
if err := settings.Yaml(keys.Hems, new(map[string]any), &conf); err != nil {
return err
}
}
if conf.Type == "" {
return nil
}
// TODO remove yaml file
// // migrate settings
// if !settings.Exists(keys.Hems) {
// if err := settings.SetYaml(keys.Hems, conf); err != nil {
// return err
// }
// }
hems, err := hems.NewFromConfig(conf.Type, conf.Other, site, httpd)
if err != nil {
return fmt.Errorf("failed configuring hems: %w", err)
@ -693,8 +688,21 @@ func configureHEMS(conf config.Typed, site *core.Site, httpd *server.HTTPd) erro
return nil
}
// networkSettings reads/migrates network settings
func networkSettings(conf *globalconfig.Network) error {
if settings.Exists(keys.Network) {
return settings.Json(keys.Network, &conf)
}
// TODO remove yaml file
// // migrate settings
// return settings.SetJson(keys.Network, conf)
return nil
}
// setup MDNS
func configureMDNS(conf networkConfig) error {
func configureMDNS(conf globalconfig.Network) error {
host := strings.TrimSuffix(conf.Host, ".local")
zc, err := zeroconf.RegisterProxy("evcc", "_http._tcp", "local.", conf.Port, host, nil, []string{"path=/"}, nil)
@ -708,7 +716,26 @@ func configureMDNS(conf networkConfig) error {
}
// setup EEBus
func configureEEBus(conf map[string]interface{}) error {
func configureEEBus(conf eebus.Config) error {
// migrate settings
if settings.Exists(keys.EEBus) {
if err := settings.Yaml(keys.EEBus, new(map[string]any), &conf); err != nil {
return err
}
}
if conf.URI == "" {
return nil
}
// TODO remove yaml file
// // migrate settings
// if !settings.Exists(keys.EEBus) {
// if err := settings.SetYaml(keys.EEBus, conf); err != nil {
// return err
// }
// }
var err error
if eebus.Instance, err = eebus.NewServer(conf); err != nil {
return fmt.Errorf("failed configuring eebus: %w", err)
@ -721,7 +748,20 @@ func configureEEBus(conf map[string]interface{}) error {
}
// setup messaging
func configureMessengers(conf messagingConfig, vehicles push.Vehicles, valueChan chan util.Param, cache *util.Cache) (chan push.Event, error) {
func configureMessengers(conf globalconfig.Messaging, vehicles push.Vehicles, valueChan chan<- util.Param, cache *util.Cache) (chan push.Event, error) {
// migrate settings
if settings.Exists(keys.Messaging) {
if err := settings.Yaml(keys.Messaging, new(map[string]any), &conf); err != nil {
return nil, err
}
// TODO remove yaml file
// } else if len(conf.Services)+len(conf.Events) > 0 {
// if err := settings.SetYaml(keys.Messaging, conf); err != nil {
// return nil, err
// }
}
messageChan := make(chan push.Event, 1)
messageHub, err := push.NewHub(conf.Events, vehicles, cache)
@ -742,23 +782,34 @@ func configureMessengers(conf messagingConfig, vehicles push.Vehicles, valueChan
return messageChan, nil
}
func configureTariff(name string, conf config.Typed, t *api.Tariff, wg *sync.WaitGroup) {
defer wg.Done()
func configureTariff(name string, conf config.Typed, t *api.Tariff) error {
if conf.Type == "" {
return
return nil
}
res, err := tariff.NewFromConfig(conf.Type, conf.Other)
if err != nil {
log.ERROR.Printf("failed configuring %s tariff: %v", name, err)
return
return &DeviceError{name, err}
}
*t = res
return nil
}
func configureTariffs(conf tariffConfig) (*tariff.Tariffs, error) {
func configureTariffs(conf globalconfig.Tariffs) (*tariff.Tariffs, error) {
// migrate settings
if settings.Exists(keys.Tariffs) {
if err := settings.Yaml(keys.Tariffs, new(map[string]any), &conf); err != nil {
return nil, err
}
// TODO remove yaml file
// } else if conf.Grid.Type != "" || conf.FeedIn.Type != "" || conf.Co2.Type != "" || conf.Planner.Type != "" {
// if err := settings.SetYaml(keys.Tariffs, conf); err != nil {
// return nil, err
// }
}
tariffs := tariff.Tariffs{
Currency: currency.EUR,
}
@ -767,45 +818,91 @@ func configureTariffs(conf tariffConfig) (*tariff.Tariffs, error) {
tariffs.Currency = currency.MustParseISO(conf.Currency)
}
var wg sync.WaitGroup
wg.Add(4)
g, _ := errgroup.WithContext(context.Background())
g.Go(func() error { return configureTariff("grid", conf.Grid, &tariffs.Grid) })
g.Go(func() error { return configureTariff("feedin", conf.FeedIn, &tariffs.FeedIn) })
g.Go(func() error { return configureTariff("co2", conf.Co2, &tariffs.Co2) })
g.Go(func() error { return configureTariff("planner", conf.Planner, &tariffs.Planner) })
go configureTariff("grid", conf.Grid, &tariffs.Grid, &wg)
go configureTariff("feedin", conf.FeedIn, &tariffs.FeedIn, &wg)
go configureTariff("co2", conf.Co2, &tariffs.Co2, &wg)
go configureTariff("planner", conf.Planner, &tariffs.Planner, &wg)
wg.Wait()
if err := g.Wait(); err != nil {
return nil, &ClassError{ClassTariff, err}
}
return &tariffs, nil
}
func configureDevices(conf globalConfig) error {
func configureDevices(conf globalconfig.All) error {
// TODO: add name/identifier to error for better highlighting in UI
if err := configureMeters(conf.Meters); err != nil {
return err
return &ClassError{ClassMeter, err}
}
if err := configureChargers(conf.Chargers); err != nil {
return err
return &ClassError{ClassCharger, err}
}
if err := configureVehicles(conf.Vehicles); err != nil {
return &ClassError{ClassVehicle, err}
}
if err := configureCircuits(conf.Circuits); err != nil {
return err
return &ClassError{ClassCircuit, err}
}
return configureVehicles(conf.Vehicles)
return nil
}
func configureSiteAndLoadpoints(conf globalConfig) (*core.Site, error) {
if err := configureDevices(conf); err != nil {
func configureModbusProxy(conf []globalconfig.ModbusProxy) error {
// migrate settings
if settings.Exists(keys.ModbusProxy) {
if err := settings.Yaml(keys.ModbusProxy, new([]map[string]any), &conf); err != nil {
return err
}
// TODO remove yaml file
// } else if len(conf) > 0 {
// if err := settings.SetYaml(keys.ModbusProxy, conf); err != nil {
// return err
// }
}
for _, cfg := range conf {
var mode modbus.ReadOnlyMode
mode, err := modbus.ReadOnlyModeString(cfg.ReadOnly)
if err != nil {
return err
}
if err = modbus.StartProxy(cfg.Port, cfg.Settings, mode); err != nil {
return err
}
}
return nil
}
func configureSiteAndLoadpoints(conf *globalconfig.All) (*core.Site, error) {
// migrate settings
if settings.Exists(keys.Interval) {
d, err := settings.Int(keys.Interval)
if err != nil {
return nil, err
}
conf.Interval = time.Duration(d)
// TODO remove yaml file
// } else if conf.Interval != 0 {
// settings.SetInt(keys.Interval, int64(conf.Interval))
}
if err := configureDevices(*conf); err != nil {
return nil, err
}
loadpoints, err := configureLoadpoints(conf)
loadpoints, err := configureLoadpoints(*conf)
if err != nil {
return nil, fmt.Errorf("failed configuring loadpoints: %w", err)
}
tariffs, err := configureTariffs(conf.Tariffs)
if err != nil {
return nil, err
return nil, &ClassError{ClassTariff, err}
}
site, err := configureSite(conf.Site, loadpoints, tariffs)
@ -815,7 +912,7 @@ func configureSiteAndLoadpoints(conf globalConfig) (*core.Site, error) {
if len(config.Circuits().Devices()) > 0 {
if err := validateCircuits(site, loadpoints); err != nil {
return nil, err
return nil, &ClassError{ClassCircuit, err}
}
}
@ -848,8 +945,13 @@ CONTINUE:
}
func configureSite(conf map[string]interface{}, loadpoints []*core.Loadpoint, tariffs *tariff.Tariffs) (*core.Site, error) {
site, err := core.NewSiteFromConfig(log, conf, loadpoints, tariffs)
site, err := core.NewSiteFromConfig(conf)
if err != nil {
// TODO proper handling
panic(err)
}
if err := site.Boot(log, loadpoints, tariffs); err != nil {
return nil, fmt.Errorf("failed configuring site: %w", err)
}
@ -860,7 +962,7 @@ func configureSite(conf map[string]interface{}, loadpoints []*core.Loadpoint, ta
return site, nil
}
func configureLoadpoints(conf globalConfig) ([]*core.Loadpoint, error) {
func configureLoadpoints(conf globalconfig.All) ([]*core.Loadpoint, error) {
if len(conf.Loadpoints) == 0 {
return nil, errors.New("missing loadpoints")
}
@ -883,7 +985,7 @@ func configureLoadpoints(conf globalConfig) ([]*core.Loadpoint, error) {
}
// configureAuth handles routing for devices. For now only api.AuthProvider related routes
func configureAuth(conf networkConfig, vehicles []api.Vehicle, router *mux.Router, paramC chan<- util.Param) {
func configureAuth(conf globalconfig.Network, vehicles []api.Vehicle, router *mux.Router, paramC chan<- util.Param) {
auth := router.PathPrefix("/oauth").Subrouter()
auth.Use(handlers.CompressHandler)
auth.Use(handlers.CORS(

View file

@ -5,6 +5,7 @@ import (
"testing"
"github.com/evcc-io/evcc/api"
"github.com/evcc-io/evcc/api/globalconfig"
"github.com/evcc-io/evcc/core"
"github.com/evcc-io/evcc/meter"
"github.com/evcc-io/evcc/server/db"
@ -35,7 +36,7 @@ func (suite *circuitsTestSuite) SetupTest() {
}
func (suite *circuitsTestSuite) TestCircuitConf() {
var conf globalConfig
var conf globalconfig.All
viper.SetConfigType("yaml")
suite.Require().NoError(viper.ReadConfig(strings.NewReader(`circuits:
@ -67,7 +68,7 @@ loadpoints:
}
func (suite *circuitsTestSuite) TestLoadpointMissingCircuitError() {
var conf globalConfig
var conf globalconfig.All
viper.SetConfigType("yaml")
suite.Require().NoError(viper.ReadConfig(strings.NewReader(`
@ -99,7 +100,7 @@ loadpoints:
}
func (suite *circuitsTestSuite) TestSiteMissingCircuitError() {
var conf globalConfig
var conf globalconfig.All
viper.SetConfigType("yaml")
suite.Require().NoError(viper.ReadConfig(strings.NewReader(`

View file

@ -5,12 +5,13 @@ import (
"testing"
"github.com/evcc-io/evcc/api"
"github.com/evcc-io/evcc/api/globalconfig"
"github.com/evcc-io/evcc/core"
"github.com/evcc-io/evcc/util"
)
func TestYamlOff(t *testing.T) {
var conf globalConfig
var conf globalconfig.All
viper.SetConfigType("yaml")
if err := viper.ReadConfig(strings.NewReader(`loadpoints:
- mode: off

View file

@ -29,7 +29,7 @@ func runTariff(cmd *cobra.Command, args []string) {
}
// setup environment
if err := configureEnvironment(cmd, conf); err != nil {
if err := configureEnvironment(cmd, &conf); err != nil {
fatal(err)
}

View file

@ -10,6 +10,7 @@ import (
"time"
"github.com/evcc-io/evcc/api"
"github.com/evcc-io/evcc/api/globalconfig"
"github.com/evcc-io/evcc/util"
"github.com/evcc-io/evcc/util/config"
"github.com/evcc-io/evcc/vehicle"
@ -100,7 +101,7 @@ func tronityAuthorize(addr string, oc *oauth2.Config) (*oauth2.Token, error) {
}
}
func tronityToken(conf globalConfig, vehicleConf config.Named) (*oauth2.Token, error) {
func tronityToken(conf globalconfig.All, vehicleConf config.Named) (*oauth2.Token, error) {
var cc struct {
Credentials vehicle.ClientCredentials
RedirectURI string

View file

@ -33,7 +33,7 @@ func runVehicle(cmd *cobra.Command, args []string) {
}
// setup environment
if err := configureEnvironment(cmd, conf); err != nil {
if err := configureEnvironment(cmd, &conf); err != nil {
fatal(err)
}

18
core/keys/global.go Normal file
View file

@ -0,0 +1,18 @@
package keys
const (
Interval = "interval"
PasswordConfigured = "passwordConfigured"
Sponsor = "sponsor"
SponsorToken = "sponsorToken"
Network = "network"
Mqtt = "mqtt"
Influx = "influx"
EEBus = "eebus"
Hems = "hems"
Messaging = "messaging"
ModbusProxy = "modbusproxy"
Tariffs = "tariffs"
Version = "version"
Fatal = "fatal"
)

View file

@ -29,8 +29,6 @@ const (
TariffPriceLoadpoints = "tariffPriceLoadpoints"
Vehicles = "vehicles"
Circuits = "circuits"
PasswordConfigured = "passwordConfigured"
Interval = "interval"
// meters
GridMeter = "gridMeter"
@ -43,6 +41,7 @@ const (
BatteryDischargeControl = "batteryDischargeControl"
BufferSoc = "bufferSoc"
BufferStartSoc = "bufferStartSoc"
MaxGridSupplyWhileBatteryCharging = "maxGridSupplyWhileBatteryCharging"
// battery status
Battery = "battery"

View file

@ -84,6 +84,7 @@ type Site struct {
prioritySoc float64 // prefer battery up to this Soc
bufferSoc float64 // continue charging on battery above this Soc
bufferStartSoc float64 // start charging on battery above this Soc
maxGridSupplyWhileBatteryCharging float64 // ignore battery charging if AC consumption is above this value
batteryDischargeControl bool // prevent battery discharge for fast and planned charging
loadpoints []*Loadpoint // Loadpoints
@ -102,7 +103,7 @@ type Site struct {
publishCache map[string]any // store last published values to avoid unnecessary republishing
}
// MetersConfig contains the loadpoint's meter configuration
// MetersConfig contains the site's meter configuration
type MetersConfig struct {
GridMeterRef string `mapstructure:"grid"` // Grid usage meter
PVMetersRef []string `mapstructure:"pv"` // PV meter
@ -111,18 +112,24 @@ type MetersConfig struct {
}
// NewSiteFromConfig creates a new site
func NewSiteFromConfig(
log *util.Logger,
other map[string]interface{},
loadpoints []*Loadpoint,
tariffs *tariff.Tariffs,
) (*Site, error) {
func NewSiteFromConfig(other map[string]interface{}) (*Site, error) {
site := NewSite()
// TODO remove
if err := util.DecodeOther(other, site); err != nil {
return nil, err
}
// add meters from config
site.restoreMetersAndTitle()
// TODO title
Voltage = site.Voltage
return site, nil
}
func (site *Site) Boot(log *util.Logger, loadpoints []*Loadpoint, tariffs *tariff.Tariffs) error {
site.loadpoints = loadpoints
site.tariffs = tariffs
@ -150,11 +157,11 @@ func NewSiteFromConfig(
if db.Instance != nil {
var err error
if lp.db, err = session.NewStore(lp.Title(), db.Instance); err != nil {
return nil, err
return err
}
// Fix any dangling history
if err := lp.db.ClosePendingSessionsInHistory(lp.chargeMeterTotal()); err != nil {
return nil, err
return err
}
// NOTE: this requires stopSession to respect async access
@ -162,14 +169,11 @@ func NewSiteFromConfig(
}
}
// add meters from config
site.restoreMeters()
// circuit
if site.CircuitRef != "" {
dev, err := config.Circuits().ByName(site.CircuitRef)
if err != nil {
return nil, err
return err
}
site.circuit = dev.Instance()
}
@ -178,7 +182,7 @@ func NewSiteFromConfig(
if site.Meters.GridMeterRef != "" {
dev, err := config.Meters().ByName(site.Meters.GridMeterRef)
if err != nil {
return nil, err
return err
}
site.gridMeter = dev.Instance()
}
@ -187,7 +191,7 @@ func NewSiteFromConfig(
for _, ref := range site.Meters.PVMetersRef {
dev, err := config.Meters().ByName(ref)
if err != nil {
return nil, err
return err
}
site.pvMeters = append(site.pvMeters, dev.Instance())
}
@ -196,12 +200,12 @@ func NewSiteFromConfig(
for _, ref := range site.Meters.BatteryMetersRef {
dev, err := config.Meters().ByName(ref)
if err != nil {
return nil, err
return err
}
site.batteryMeters = append(site.batteryMeters, dev.Instance())
}
if len(site.batteryMeters) > 0 && site.ResidualPower <= 0 {
if len(site.batteryMeters) > 0 && site.GetResidualPower() <= 0 {
site.log.WARN.Println("battery configured but residualPower is missing or <= 0 (add residualPower: 100 to site), see https://docs.evcc.io/en/docs/reference/configuration/site#residualpower")
}
@ -209,14 +213,14 @@ func NewSiteFromConfig(
for _, ref := range site.Meters.AuxMetersRef {
dev, err := config.Meters().ByName(ref)
if err != nil {
return nil, err
return err
}
site.auxMeters = append(site.auxMeters, dev.Instance())
}
// configure meter from references
if site.gridMeter == nil && len(site.pvMeters) == 0 {
return nil, errors.New("missing either grid or pv meter")
return errors.New("missing either grid or pv meter")
}
// revert battery mode on shutdown
@ -228,7 +232,7 @@ func NewSiteFromConfig(
}
})
return site, nil
return nil
}
// NewSite creates a Site with sane defaults
@ -242,11 +246,14 @@ func NewSite() *Site {
return lp
}
// restoreMeters restores site meter configuration
func (site *Site) restoreMeters() {
// restoreMetersAndTitle restores site meter configuration
func (site *Site) restoreMetersAndTitle() {
if testing.Testing() {
return
}
if v, err := settings.String(keys.Title); err == nil {
site.Title = v
}
if v, err := settings.String(keys.GridMeter); err == nil && v != "" {
site.Meters.GridMeterRef = v
}
@ -266,9 +273,6 @@ func (site *Site) restoreSettings() error {
if testing.Testing() {
return nil
}
if v, err := settings.String(keys.Title); err == nil {
site.Title = v
}
if v, err := settings.Float(keys.BufferSoc); err == nil {
if err := site.SetBufferSoc(v); err != nil {
return err
@ -279,6 +283,12 @@ func (site *Site) restoreSettings() error {
return err
}
}
// TODO migrate from YAML
if v, err := settings.Float(keys.MaxGridSupplyWhileBatteryCharging); err == nil {
if err := site.SetMaxGridSupplyWhileBatteryCharging(v); err != nil {
return err
}
}
if v, err := settings.Float(keys.PrioritySoc); err == nil {
if err := site.SetPrioritySoc(v); err != nil {
return err
@ -289,6 +299,11 @@ func (site *Site) restoreSettings() error {
return err
}
}
if v, err := settings.Float(keys.ResidualPower); err == nil {
if err := site.SetResidualPower(v); err != nil {
return err
}
}
return nil
}
@ -394,10 +409,6 @@ func (site *Site) publish(key string, val interface{}) {
return
}
if s, ok := val.(fmt.Stringer); ok {
val = s.String()
}
site.uiChan <- util.Param{Key: key, Val: val}
}
@ -625,7 +636,7 @@ func (site *Site) sitePower(totalChargePower, flexiblePower float64) (float64, b
// allow using grid and charge as estimate for pv power
if site.pvMeters == nil {
site.pvPower = totalChargePower - site.gridPower + site.ResidualPower
site.pvPower = totalChargePower - site.gridPower + site.GetResidualPower()
if site.pvPower < 0 {
site.pvPower = 0
}
@ -654,7 +665,7 @@ func (site *Site) sitePower(totalChargePower, flexiblePower float64) (float64, b
}
}
sitePower := sitePower(site.log, site.MaxGridSupplyWhileBatteryCharging, site.gridPower, batteryPower, site.ResidualPower)
sitePower := sitePower(site.log, site.GetMaxGridSupplyWhileBatteryCharging(), site.gridPower, batteryPower, site.GetResidualPower())
// deduct smart loads
if len(site.auxMeters) > 0 {
@ -837,12 +848,13 @@ func (site *Site) prepare() {
site.publish(keys.GridConfigured, site.gridMeter != nil)
site.publish(keys.Pv, make([]api.Meter, len(site.pvMeters)))
site.publish(keys.Battery, make([]api.Meter, len(site.batteryMeters)))
site.publish(keys.PrioritySoc, site.prioritySoc)
site.publish(keys.BufferSoc, site.bufferSoc)
site.publish(keys.BufferStartSoc, site.bufferStartSoc)
site.publish(keys.PrioritySoc, site.prioritySoc)
site.publish(keys.MaxGridSupplyWhileBatteryCharging, site.maxGridSupplyWhileBatteryCharging)
site.publish(keys.BatteryMode, site.batteryMode)
site.publish(keys.BatteryDischargeControl, site.batteryDischargeControl)
site.publish(keys.ResidualPower, site.ResidualPower)
site.publish(keys.ResidualPower, site.GetResidualPower())
site.publish(keys.Currency, site.tariffs.Currency)
if tariff := site.GetTariff(PlannerTariff); tariff != nil {
@ -921,7 +933,6 @@ func (site *Site) Run(stopC chan struct{}, interval time.Duration) {
go site.loopLoadpoints(loadpointChan)
ticker := time.NewTicker(interval)
site.publish(keys.Interval, interval.Seconds())
site.update(<-loadpointChan) // start immediately
for {

View file

@ -30,12 +30,14 @@ type API interface {
// battery
//
GetPrioritySoc() float64
SetPrioritySoc(float64) error
GetBufferSoc() float64
SetBufferSoc(float64) error
GetBufferStartSoc() float64
SetBufferStartSoc(float64) error
GetPrioritySoc() float64
SetPrioritySoc(float64) error
GetMaxGridSupplyWhileBatteryCharging() float64
SetMaxGridSupplyWhileBatteryCharging(float64) error
//
// power and energy

View file

@ -243,6 +243,31 @@ func (site *Site) SetBufferStartSoc(soc float64) error {
return nil
}
func (site *Site) GetMaxGridSupplyWhileBatteryCharging() float64 {
site.RLock()
defer site.RUnlock()
return site.maxGridSupplyWhileBatteryCharging
}
func (site *Site) SetMaxGridSupplyWhileBatteryCharging(power float64) error {
site.Lock()
defer site.Unlock()
if len(site.batteryMeters) == 0 {
return ErrBatteryNotConfigured
}
site.log.DEBUG.Println("set max grid supply while battery charging:", power)
if site.maxGridSupplyWhileBatteryCharging != power {
site.maxGridSupplyWhileBatteryCharging = power
settings.SetFloat(keys.MaxGridSupplyWhileBatteryCharging, site.maxGridSupplyWhileBatteryCharging)
site.publish(keys.MaxGridSupplyWhileBatteryCharging, site.maxGridSupplyWhileBatteryCharging)
}
return nil
}
// GetResidualPower returns the ResidualPower
func (site *Site) GetResidualPower() float64 {
site.RLock()
@ -252,13 +277,14 @@ func (site *Site) GetResidualPower() float64 {
// SetResidualPower sets the ResidualPower
func (site *Site) SetResidualPower(power float64) error {
site.log.DEBUG.Println("set residual power:", power)
site.Lock()
defer site.Unlock()
site.log.DEBUG.Println("set residual power:", power)
if site.ResidualPower != power {
site.ResidualPower = power
settings.SetFloat(keys.ResidualPower, site.ResidualPower)
site.publish(keys.ResidualPower, site.ResidualPower)
}
@ -312,7 +338,10 @@ func (site *Site) GetBatteryDischargeControl() bool {
func (site *Site) SetBatteryDischargeControl(val bool) error {
site.log.DEBUG.Println("set battery discharge control:", val)
if site.GetBatteryDischargeControl() != val {
site.Lock()
defer site.Unlock()
if site.batteryDischargeControl != val {
// reset to normal when disabling
if mode := site.GetBatteryMode(); !val && batteryModeModified(mode) {
if err := site.applyBatteryMode(api.BatteryNormal); err != nil {
@ -320,9 +349,6 @@ func (site *Site) SetBatteryDischargeControl(val bool) error {
}
}
site.Lock()
defer site.Unlock()
site.batteryDischargeControl = val
settings.SetBool(keys.BatteryDischargeControl, val)
site.publish(keys.BatteryDischargeControl, val)

9
docs/encoding.md Normal file
View file

@ -0,0 +1,9 @@
## API encoding
| | socket | /api/status | mqtt | push | influx | rest |
| ------------- | -------------------- | ----------------------------- | --------- | ------------- | ------------- | ---- |
| source | chan<br>encode.New() | cache.State()<br>encode.New() | chan | cache.All() | chan | - |
| time.Time | rfc3999/nil | rfc3999/nil | rfc3999 | time.Time | time.Time | ? |
| time.Duration | s | s | s | time.Duration | time.Duration | s |
| fmt.Stringer | string | string | string | string | string | ? |
| struct{} | ? | recursive | recursive | n/a | ? | ? |

2
go.mod
View file

@ -194,3 +194,5 @@ require (
modernc.org/memory v1.8.0 // indirect
modernc.org/sqlite v1.29.9 // indirect
)
replace gopkg.in/yaml.v3 => github.com/andig/yaml v0.0.0-20240531135838-1ff5761ab467

5
go.sum
View file

@ -33,6 +33,8 @@ github.com/andig/gosunspec v0.0.0-20231205122018-1daccfa17912 h1:pphS+WUa9zK0ZH/
github.com/andig/gosunspec v0.0.0-20231205122018-1daccfa17912/go.mod h1:c6P6szcR+ROkqZruOR4f6qbDKFjZX6OitPpj+yJ/r8k=
github.com/andig/mbserver v0.0.0-20230310211055-1d29cbb5820e h1:m/NTP3JWpR7M0ljLxiQU4fzR25jjhe1LDtxLMNcoNJQ=
github.com/andig/mbserver v0.0.0-20230310211055-1d29cbb5820e/go.mod h1:4VtYzTm//oUipwvO3yh0g/udTE7pYJM+U/kyAuFDsgM=
github.com/andig/yaml v0.0.0-20240531135838-1ff5761ab467 h1:JqIoHxsQSV39xaemvVRu3HOkSf5AbhQ1YEENJa2cL3Q=
github.com/andig/yaml v0.0.0-20240531135838-1ff5761ab467/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
github.com/andybalholm/cascadia v1.3.2 h1:3Xi6Dw5lHF15JtdcmAHD3i1+T8plmv7BQ/nsViSLyss=
github.com/andybalholm/cascadia v1.3.2/go.mod h1:7gtRlve5FxPPgIgX36uWBX58OdBsSS6lUvCFb+h7KvU=
github.com/apache/thrift v0.12.0/go.mod h1:cp2SuWMxlEZw2r+iP2GNCdIi4C1qmUzdZFSVb+bacwQ=
@ -921,9 +923,6 @@ gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gorm.io/gorm v1.25.10 h1:dQpO+33KalOA+aFYGlK+EfxcI5MbO7EP2yYygwh9h+s=
gorm.io/gorm v1.25.10/go.mod h1:hbnx/Oo0ChWMn1BIhpy1oYozzpM15i4YPuHDmfYtwg8=
honnef.co/go/tools v0.0.0-20180728063816-88497007e858/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=

View file

@ -5,7 +5,6 @@ import (
"strings"
"github.com/evcc-io/evcc/core/site"
"github.com/evcc-io/evcc/hems/ocpp"
"github.com/evcc-io/evcc/hems/semp"
"github.com/evcc-io/evcc/server"
)
@ -20,8 +19,8 @@ func NewFromConfig(typ string, other map[string]interface{}, site site.API, http
switch strings.ToLower(typ) {
case "sma", "shm", "semp":
return semp.New(other, site, httpd)
case "ocpp":
return ocpp.New(other, site)
// case "ocpp":
// return ocpp.New(other, site)
default:
return nil, errors.New("unknown hems: " + typ)
}

View file

@ -1,98 +0,0 @@
package ocpp
import (
"fmt"
"math/rand"
"strings"
"time"
"github.com/evcc-io/evcc/api"
"github.com/evcc-io/evcc/core/site"
"github.com/evcc-io/evcc/hems/ocpp/profile"
"github.com/evcc-io/evcc/util"
"github.com/evcc-io/evcc/util/machine"
ocpp16 "github.com/lorenzodonini/ocpp-go/ocpp1.6"
ocppcore "github.com/lorenzodonini/ocpp-go/ocpp1.6/core"
"github.com/lorenzodonini/ocpp-go/ws"
)
// OCPP is an OCPP client
type OCPP struct {
log *util.Logger
site site.API
cp ocpp16.ChargePoint
}
const retryTimeout = 5 * time.Second
// New generates OCPP chargepoint client
func New(conf map[string]interface{}, site site.API) (*OCPP, error) {
var cc struct {
URI string
StationID string
}
if err := util.DecodeOther(conf, &cc); err != nil {
return nil, err
}
log := util.NewLogger("ocpp")
if cc.StationID == "" {
id, err := machine.ProtectedID("evcc-ocpp")
if err == nil {
cc.StationID = fmt.Sprintf("evcc-%s", strings.ToLower(id))
} else {
cc.StationID = fmt.Sprintf("evcc-%d", rand.Int31())
}
log.DEBUG.Println("station id:", cc.StationID)
}
ws := ws.NewClient()
cp := ocpp16.NewChargePoint(cc.StationID, nil, ws)
s := &OCPP{
log: log,
site: site,
cp: cp,
}
err := cp.Start(cc.URI)
if err == nil {
cp.SetCoreHandler(profile.NewCore(log, profile.GetDefaultConfig()))
cp.SetSmartChargingHandler(profile.NewSmartCharging(log))
go s.errorHandler(ws.Errors())
go s.errorHandler(cp.Errors())
}
return s, err
}
// errorHandler logs error channel
func (s *OCPP) errorHandler(errC <-chan error) {
for err := range errC {
s.log.ERROR.Println(err)
}
}
// Run executes the OCPP chargepoint client
func (s *OCPP) Run() {
for {
for id, lp := range s.site.Loadpoints() {
connector := id + 1
status := ocppcore.ChargePointStatusAvailable
if lp.GetStatus() == api.StatusC {
status = ocppcore.ChargePointStatusCharging
}
s.log.DEBUG.Printf("send: lp-%d status: %+v", connector, status)
if _, err := s.cp.StatusNotification(connector, ocppcore.NoError, status); err != nil {
s.log.ERROR.Printf("lp-%d: %v", connector, err)
}
}
time.Sleep(retryTimeout)
}
}

View file

@ -1,135 +0,0 @@
package profile
import (
"strconv"
"github.com/lorenzodonini/ocpp-go/ocpp1.6/core"
"github.com/lorenzodonini/ocpp-go/ocpp1.6/types"
)
// modified from https://github.com/lorenzodonini/ocpp-go/tree/master/example/1.6/cp
// configuration options
const (
AuthorizeRemoteTxRequests string = "AuthorizeRemoteTxRequests"
ClockAlignedDataInterval string = "ClockAlignedDataInterval"
ConnectionTimeOut string = "ConnectionTimeOut"
ConnectorPhaseRotation string = "ConnectorPhaseRotation"
GetConfigurationMaxKeys string = "GetConfigurationMaxKeys"
HeartbeatInterval string = "HeartbeatInterval"
LocalAuthorizeOffline string = "LocalAuthorizeOffline"
LocalPreAuthorize string = "LocalPreAuthorize"
MeterValuesAlignedData string = "MeterValuesAlignedData"
MeterValuesSampledData string = "MeterValuesSampledData"
MeterValueSampleInterval string = "MeterValueSampleInterval"
NumberOfConnectors string = "NumberOfConnectors"
ResetRetries string = "ResetRetries"
StopTransactionOnEVSideDisconnect string = "StopTransactionOnEVSideDisconnect"
StopTransactionOnInvalidID string = "StopTransactionOnInvalidId"
StopTxnAlignedData string = "StopTxnAlignedData"
StopTxnSampledData string = "StopTxnSampledData"
SupportedFeatureProfiles string = "SupportedFeatureProfiles"
TransactionMessageAttempts string = "TransactionMessageAttempts"
TransactionMessageRetryInterval string = "TransactionMessageRetryInterval"
UnlockConnectorOnEVSideDisconnect string = "UnlockConnectorOnEVSideDisconnect"
WebSocketPingInterval string = "WebSocketPingInterval"
LocalAuthListEnabled string = "LocalAuthListEnabled"
LocalAuthListMaxLength string = "LocalAuthListMaxLength"
SendLocalListMaxLength string = "SendLocalListMaxLength"
ChargeProfileMaxStackLevel string = "ChargeProfileMaxStackLevel"
ChargingScheduleAllowedChargingRateUnit string = "ChargingScheduleAllowedChargingRateUnit"
ChargingScheduleMaxPeriods string = "ChargingScheduleMaxPeriods"
MaxChargingProfilesInstalled string = "MaxChargingProfilesInstalled"
)
// ConfigMap defines the active configuration settings
type ConfigMap map[string]core.ConfigurationKey
// func (c ConfigMap) updateInt(key string, i int64) {
// configKey, ok := c[key]
// if ok {
// configKey.Value = strconv.FormatInt(i, 10)
// }
// c[key] = configKey
// }
// func (c ConfigMap) updateBool(key string, b bool) {
// configKey, ok := c[key]
// if ok {
// configKey.Value = strconv.FormatBool(b)
// }
// c[key] = configKey
// }
// func (c ConfigMap) getInt(key string) (int, bool) {
// configKey, ok := c[key]
// if !ok {
// return 0, ok
// }
// result, err := strconv.ParseInt(configKey.Value, 10, 32)
// if err != nil {
// return 0, false
// }
// return int(result), true
// }
// func (c ConfigMap) getBool(key string) (bool, bool) {
// configKey, ok := c[key]
// if !ok {
// return false, ok
// }
// result, err := strconv.ParseBool(configKey.Value)
// if err != nil {
// return false, false
// }
// return result, true
// }
func (c ConfigMap) set(key string, readonly bool, value string) {
c[key] = core.ConfigurationKey{
Key: key,
Readonly: readonly,
Value: &value,
}
}
func GetDefaultConfig() ConfigMap {
intBase := 10
var cfg ConfigMap = make(map[string]core.ConfigurationKey)
// readonly
cfg.set(SupportedFeatureProfiles, true, core.ProfileName)
cfg.set(AuthorizeRemoteTxRequests, true, strconv.FormatBool(false))
cfg.set(GetConfigurationMaxKeys, true, strconv.FormatInt(50, intBase))
cfg.set(NumberOfConnectors, true, strconv.FormatInt(1, intBase))
cfg.set(LocalAuthListMaxLength, true, strconv.FormatInt(100, intBase))
cfg.set(SendLocalListMaxLength, true, strconv.FormatInt(20, intBase))
cfg.set(ChargeProfileMaxStackLevel, true, strconv.FormatInt(10, intBase))
cfg.set(ChargingScheduleAllowedChargingRateUnit, true, "Power")
cfg.set(ChargingScheduleMaxPeriods, true, strconv.FormatInt(5, intBase))
cfg.set(MaxChargingProfilesInstalled, true, strconv.FormatInt(10, intBase))
// read/write
cfg.set(ClockAlignedDataInterval, false, strconv.FormatInt(0, intBase))
cfg.set(ConnectionTimeOut, false, strconv.FormatInt(60, intBase))
cfg.set(ConnectorPhaseRotation, false, "Unknown")
cfg.set(HeartbeatInterval, false, strconv.FormatInt(86400, intBase))
cfg.set(LocalAuthorizeOffline, false, strconv.FormatBool(true))
cfg.set(LocalAuthListEnabled, false, strconv.FormatBool(true))
cfg.set(LocalPreAuthorize, false, strconv.FormatBool(false))
cfg.set(MeterValuesAlignedData, false, string(types.MeasurandEnergyActiveExportRegister))
cfg.set(MeterValuesSampledData, false, string(types.MeasurandEnergyActiveExportRegister))
cfg.set(MeterValueSampleInterval, false, strconv.FormatInt(5, intBase))
cfg.set(ResetRetries, false, strconv.FormatInt(10, intBase))
cfg.set(StopTransactionOnEVSideDisconnect, false, strconv.FormatBool(true))
cfg.set(StopTransactionOnInvalidID, false, strconv.FormatBool(true))
cfg.set(StopTxnAlignedData, false, strconv.FormatBool(true))
cfg.set(StopTxnSampledData, false, string(types.MeasurandEnergyActiveExportRegister))
cfg.set(TransactionMessageAttempts, false, strconv.FormatInt(5, intBase))
cfg.set(TransactionMessageRetryInterval, false, strconv.FormatInt(60, intBase))
cfg.set(UnlockConnectorOnEVSideDisconnect, false, strconv.FormatBool(true))
cfg.set(WebSocketPingInterval, false, strconv.FormatInt(54, intBase))
return cfg
}

View file

@ -1,43 +0,0 @@
package profile
import (
"github.com/evcc-io/evcc/util"
"github.com/lorenzodonini/ocpp-go/ocpp1.6/core"
"github.com/lorenzodonini/ocpp-go/ocpp1.6/types"
)
type Core struct {
log *util.Logger
configuration ConfigMap
}
func NewCore(log *util.Logger, config ConfigMap) *Core {
return &Core{
log: log,
configuration: config,
}
}
// OnChangeAvailability handles the CS message
func (s *Core) OnChangeAvailability(request *core.ChangeAvailabilityRequest) (confirmation *core.ChangeAvailabilityConfirmation, err error) {
s.log.TRACE.Printf("recv: %s %+v", request.GetFeatureName(), request)
return core.NewChangeAvailabilityConfirmation(core.AvailabilityStatusRejected), nil
}
// OnUnlockConnector handles the CS message
func (s *Core) OnUnlockConnector(request *core.UnlockConnectorRequest) (confirmation *core.UnlockConnectorConfirmation, err error) {
s.log.TRACE.Printf("recv: %s %+v", request.GetFeatureName(), request)
return core.NewUnlockConnectorConfirmation(core.UnlockStatusUnlocked), nil
}
// OnRemoteStartTransaction handles the CS message
func (s *Core) OnRemoteStartTransaction(request *core.RemoteStartTransactionRequest) (confirmation *core.RemoteStartTransactionConfirmation, err error) {
s.log.TRACE.Printf("recv: %s %+v", request.GetFeatureName(), request)
return core.NewRemoteStartTransactionConfirmation(types.RemoteStartStopStatusRejected), nil
}
// OnRemoteStopTransaction handles the CS message
func (s *Core) OnRemoteStopTransaction(request *core.RemoteStopTransactionRequest) (confirmation *core.RemoteStopTransactionConfirmation, err error) {
s.log.TRACE.Printf("recv: %s %+v", request.GetFeatureName(), request)
return core.NewRemoteStopTransactionConfirmation(types.RemoteStartStopStatusRejected), nil
}

View file

@ -1,41 +0,0 @@
package profile
import (
"github.com/lorenzodonini/ocpp-go/ocpp1.6/core"
"golang.org/x/exp/maps"
)
// OnGetConfiguration handles the CS message
func (s *Core) OnGetConfiguration(request *core.GetConfigurationRequest) (confirmation *core.GetConfigurationConfirmation, err error) {
s.log.TRACE.Printf("recv: %s %+v", request.GetFeatureName(), request)
var resultKeys []core.ConfigurationKey
var unknownKeys []string
for _, key := range request.Key {
configKey, ok := s.configuration[key]
if !ok {
unknownKeys = append(unknownKeys, *configKey.Value)
} else {
resultKeys = append(resultKeys, configKey)
}
}
// return config for all keys
if len(request.Key) == 0 {
resultKeys = maps.Values(s.configuration)
}
s.log.TRACE.Printf("%s: configuration for requested keys: %v", request.GetFeatureName(), request.Key)
conf := core.NewGetConfigurationConfirmation(resultKeys)
conf.UnknownKey = unknownKeys
return conf, nil
}
// OnChangeConfiguration handles the CS message
func (s *Core) OnChangeConfiguration(request *core.ChangeConfigurationRequest) (confirmation *core.ChangeConfigurationConfirmation, err error) {
s.log.TRACE.Printf("recv: %s %+v", request.GetFeatureName(), request)
return core.NewChangeConfigurationConfirmation(core.ConfigurationStatusAccepted), nil
}

View file

@ -1,23 +0,0 @@
package profile
import (
"github.com/lorenzodonini/ocpp-go/ocpp1.6/core"
)
// OnClearCache handles the CS message
func (s *Core) OnClearCache(request *core.ClearCacheRequest) (confirmation *core.ClearCacheConfirmation, err error) {
s.log.TRACE.Printf("recv: %s", request.GetFeatureName())
return core.NewClearCacheConfirmation(core.ClearCacheStatusAccepted), nil
}
// OnDataTransfer handles the CS message
func (s *Core) OnDataTransfer(request *core.DataTransferRequest) (confirmation *core.DataTransferConfirmation, err error) {
s.log.TRACE.Printf("recv: %s %+v", request.GetFeatureName(), request)
return core.NewDataTransferConfirmation(core.DataTransferStatusAccepted), nil
}
// OnReset handles the CS message
func (s *Core) OnReset(request *core.ResetRequest) (confirmation *core.ResetConfirmation, err error) {
s.log.TRACE.Printf("recv: %s %+v", request.GetFeatureName(), request)
return core.NewResetConfirmation(core.ResetStatusAccepted), nil
}

View file

@ -1,34 +0,0 @@
package profile
import (
"github.com/evcc-io/evcc/util"
sc "github.com/lorenzodonini/ocpp-go/ocpp1.6/smartcharging"
)
type SmartCharging struct {
log *util.Logger
}
func NewSmartCharging(log *util.Logger) *SmartCharging {
return &SmartCharging{
log: log,
}
}
// OnSetChargingProfile handles the CS message
func (s *SmartCharging) OnSetChargingProfile(request *sc.SetChargingProfileRequest) (confirmation *sc.SetChargingProfileConfirmation, err error) {
s.log.TRACE.Printf("recv: %s %+v", request.GetFeatureName(), request)
return sc.NewSetChargingProfileConfirmation(sc.ChargingProfileStatusRejected), nil
}
// OnClearChargingProfile handles the CS message
func (s *SmartCharging) OnClearChargingProfile(request *sc.ClearChargingProfileRequest) (confirmation *sc.ClearChargingProfileConfirmation, err error) {
s.log.TRACE.Printf("recv: %s %+v", request.GetFeatureName(), request)
return sc.NewClearChargingProfileConfirmation(sc.ClearChargingProfileStatusUnknown), nil
}
// OnGetCompositeSchedule handles the CS message
func (s *SmartCharging) OnGetCompositeSchedule(request *sc.GetCompositeScheduleRequest) (confirmation *sc.GetCompositeScheduleConfirmation, err error) {
s.log.TRACE.Printf("recv: %s %+v", request.GetFeatureName(), request)
return sc.NewGetCompositeScheduleConfirmation(sc.GetCompositeScheduleStatusRejected), nil
}

View file

@ -26,13 +26,36 @@ never = "nur mit genug PV-Überschuss"
titleAdd = "Batterie Zähler hinzufügen"
titleEdit = "Batterie Zähler 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"
[config.control]
description = "Normalerweise sind die Standardwerte in Ordnung. Ändere nur etwas, wenn du weißt, was du tust."
descriptionInterval = "Regelkreis Aktualisierungszyklus in Sekunden. Definiert, wie oft evcc Messdaten liest, die Ladeleistung anpasst und die Benutzeroberfläche aktualisiert. Kurze Intervalle (< 30s) können zu Schwingungen und unerwünschtem Verhalten führen."
descriptionMaxGridSupply = "Nur relevant für Hybrid-Wechselrichter, die nicht in der Lage sind, die volle DC-Erzeugungsleistung über AC an das Haus auszugeben. Dieses Szenario kann zu unerwünschtem Netzbezug führen, da evcc davon ausgeht, dass die volle DC-Leistung verfügbar ist. Verwende einen Wert von mindestens 50 W, um dies zu verhindern."
descriptionResidualPower = "Verschiebt den Betriebspunkt des Regelkreises. Wenn du eine Hausbatterie hast, wird empfohlen, einen Wert von 100 W einzustellen. Auf diese Weise erhält die Batterie eine leichte Priorität gegenüber dem Netzbezug."
labelInterval = "Aktualisierungsintervall"
labelMaxGridSupply = "Max. Netzbezug während des Ladens"
labelResidualPower = "Residualleistung"
title = "Regelverhalten"
[config.deviceValue]
capacity = "Kapazität"
chargeStatus = "Status"
chargeStatusA = "nicht verbunden"
chargeStatusB = "verbunden"
chargeStatusC = "lädt"
chargeStatusE = "keine Leistung"
chargeStatusF = "Fehler"
chargedEnergy = "Geladen"
co2 = "Netz-CO₂"
currency = "Währung"
current = "Strom"
enabled = "Aktiviert"
energy = "Energie"
feedinPrice = "Einspeisevergütung"
gridPrice = "Netzpreis"
odometer = "Kilometerstand"
phaseCurrents = "Strom L1..L3"
phasePowers = "Leistung L1..L3"
@ -41,6 +64,11 @@ power = "Leistung"
range = "Reichweite"
soc = "SoC"
socLimit = "SoC Begrenzung"
temp = "Temperatur"
[config.eebus]
description = "Grundkonfiguration für die Kommunikation mit anderen EEBus-Geräten."
title = "EEBus"
[config.form]
example = "Beispiel"
@ -48,22 +76,54 @@ optional = "optional"
[config.general]
cancel = "Abbrechen"
docsLink = "Siehe Dokumentation."
experimental = "Experimentell"
off = "aus"
on = "an"
password = "Passwort"
remove = "Entfernen"
save = "Speichern"
telemetry = "Telemetrie"
title = "Titel"
[config.grid]
titleAdd = "Stromzähler (Netz) hinzufügen"
titleEdit = "Stromzähler (Netz) bearbeiten"
title = "Netzzähler"
titleAdd = "Netzzähler hinzufügen"
titleEdit = "Netzzähler bearbeiten"
[config.hems]
description = "Connect evcc to another home energy management system."
title = "HEMS"
[config.influx]
description = "Schreibt Ladedaten und andere Metriken in InfluxDB. Verwende Grafana oder andere Tools, um die Daten zu visualisieren."
descriptionToken = "Siehe die InfluxDB-Dokumentation, um zu erfahren, wie du einen erstellst. https://docs.influxdata.com/influxdb/v2/admin/"
labelBucket = "Bucket"
labelDatabase = "Datenbank"
labelOrg = "Organisation"
labelPassword = "Passwort"
labelToken = "API-Token"
labelUrl = "URL"
labelUser = "Benutzer"
title = "InfluxDB"
v1Support = "Unterstützung für InfluxDB 1.x?"
v2Support = "Zurück zu InfluxDB 2.x"
[config.main]
addLoadpoint = "Ladepunkt hinzufügen"
addPvBattery = "PV oder Speicher hinzufügen"
addVehicle = "Fahrzeug hinzufügen"
configured = "konfiguriert"
edit = "bearbeiten"
title = "Konfiguration"
unconfigured = "nicht konfiguriert"
vehicles = "Meine Fahrzeuge"
yaml = "Konfiguration in evcc.yaml gefunden. Nicht per UI editierbar"
[config.messaging]
description = "Erhalte Benachrichtigungen über Ladevorgänge und andere Ereignisse."
title = "Benachrichtigungen"
[config.meter]
cancel = "Abbrechen"
delete = "Löschen"
@ -72,6 +132,35 @@ template = "Hersteller"
titleChoice = "Was möchtest du hinzufügen?"
validateSave = "Überprüfen & speichern"
[config.modbusproxy]
description = "Erlaubt mehreren Clients den Zugriff auf ein einzelnes Modbus-Gerät."
title = "Modbus-Proxy"
[config.mqtt]
authentication = "Authentifizierung"
brokerLabel = "Server"
description = "Verbinde evcc mit einem MQTT-Broker, um Daten mit anderen Systemen in deinem Netzwerk auszutauschen."
descriptionClientId = "Autor der Nachrichten. Wenn leer, wird `evcc-[rand]` verwendet."
descriptionTopic = "Leer lassen, um das Publizieren zu deaktivieren."
labelBroker = "Broker"
labelCheckInsecure = "Erlaube unsichere Verbindungen"
labelClientId = "Client ID"
labelInsecure = "Zertifikatüberprüfung"
labelPassword = "Passwort"
labelTopic = "Topic"
labelUser = "Benutzer"
publishing = "Veröffentlichen"
title = "MQTT"
[config.network]
descriptionHost = "Verwende den .local-Suffix, um mDNS zu aktivieren. Wird zur Erkennung der mobilen App und einiger OCPP-Wallboxen benötigt."
descriptionPort = "Port für die Web-Oberfläche und API. Du musst deine Browser-URL aktualisieren, wenn du dies änderst."
descriptionSchema = "Beeinflusst nur die URL-Generierung. Die Auswahl von HTTPS aktiviert keine Verschlüsselung."
labelHost = "Hostname"
labelPort = "Port"
labelSchema = "Schema"
title = "Netzwerk"
[config.options]
[config.options.endianness]
@ -88,7 +177,21 @@ titleEdit = "Zähler (PV) bearbeiten"
[config.section]
general = "Allgemein"
grid = "Netzanschluss"
integrations = "Integrationen"
loadpoints = "Ladepunkte"
meter = "PV & Batterie"
system = "System"
vehicles = "Fahrzeuge"
[config.sponsor]
addToken = "Sponsortoken eingeben"
changeToken = "Sponsortoken ä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"
[config.system]
logs = "Logs"
@ -98,6 +201,10 @@ restartRequiredMessage = "Konfiguration geändert."
restartingDescription = "Bitte warten …"
restartingMessage = "evcc wird neu gestartet."
[config.tariffs]
description = "Hinterlege deine Stromtarife, um die Kosten zu berechnen."
title = "Tarife"
[config.title]
description = "Wird in der Hauptansicht und im Browser-Tab angezeigt."
label = "Titel"
@ -515,7 +622,7 @@ auto = "Automatisch"
label = "Sprache"
[settings.sponsorToken]
expires = "Dein Sponsortoken läuft {inXDays} ab. {getNewToken} und aktualisiere deine Konfigurationsdatei."
expires = "Dein Sponsortoken läuft {inXDays} ab. {getNewToken} und aktualisiere es hier."
getNew = "Hol dir ein Neues"
hint = "Hinweis: Wir werden das zukünftig automatisieren."

View file

@ -26,14 +26,42 @@ never = "only with enough surplus"
titleAdd = "Add Battery Meter"
titleEdit = "Edit Battery Meter"
[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"
[config.control]
description = "Usually the default values are fine. Only change them if you know what you are doing."
descriptionInterval = "Control loop update cycle in seconds. Defines how often evcc reads meter data, adjusts charging power and updates the UI. Short intervals (< 30s) can cause oszillations and unwanted behavior."
descriptionMaxGridSupply = "Only relevant for hybrid inverters that are not able to output the full DC generation power to the home via AC. This szenario can lead to unwanted grid use, since evcc assumes the full DC power is available. Use a value of at least 50 W to prevent this."
descriptionResidualPower = "Shifts the operation point of the control loop. If you have a home battery it's recommended to set a value of 100 W. This way the battery will get slight priority over grid use."
labelInterval = "Update interval"
labelMaxGridSupply = "Max. grid supply while charging"
labelResidualPower = "Residual power"
title = "Control behavior"
[config.deviceValue]
broker = "Broker"
bucket = "Bucket"
capacity = "Capacity"
chargeStatus = "Status"
chargeStatusA = "not connected"
chargeStatusB = "connected"
chargeStatusC = "charging"
chargeStatusE = "no power"
chargeStatusF = "error"
chargedEnergy = "Charged"
co2 = "Grid CO₂"
configured = "Configured"
currency = "Currency"
current = "Current"
enabled = "Enabled"
energy = "Energy"
feedinPrice = "Feed-in price"
gridPrice = "Grid price"
no = "no"
odometer = "Odometer"
org = "Organization"
phaseCurrents = "Current L1..L3"
phasePowers = "Power L1..L3"
phaseVoltages = "Voltage L1..L3"
@ -41,6 +69,14 @@ power = "Power"
range = "Range"
soc = "SoC"
socLimit = "Limit"
temp = "Temperature"
topic = "Topic"
url = "URL"
yes = "yes"
[config.eebus]
description = "Configuration that enables evcc to communicate with other EEBus devices."
title = "EEBus"
[config.form]
example = "Example"
@ -48,22 +84,54 @@ optional = "optional"
[config.general]
cancel = "Cancel"
docsLink = "See documentation."
experimental = "Experimental"
off = "off"
on = "on"
password = "Password"
remove = "Remove"
save = "Save"
telemetry = "Telemetry"
title = "Title"
[config.grid]
title = "Grid meter"
titleAdd = "Add Grid Meter"
titleEdit = "Edit Grid Meter"
[config.hems]
description = "Connect evcc to another home energy management system."
title = "HEMS"
[config.influx]
description = "Writes charging data and other metrics to InfluxDB. Use Grafana or other tools to visualize the data."
descriptionToken = "Check the InfluxDB documentation to learn how to create one. https://docs.influxdata.com/influxdb/v2/admin/"
labelBucket = "Bucket"
labelDatabase = "Database"
labelOrg = "Organization"
labelPassword = "Password"
labelToken = "API Token"
labelUrl = "URL"
labelUser = "Username"
title = "InfluxDB"
v1Support = "Need support for InfluxDB 1.x?"
v2Support = "Back to InfluxDB 2.x"
[config.main]
addLoadpoint = "Add charge point"
addPvBattery = "Add solar or battery"
addVehicle = "Add vehicle"
configured = "configured"
edit = "edit"
title = "Configuration"
unconfigured = "not configured"
vehicles = "My Vehicles"
yaml = "Configured in evcc.yaml. Not editable in the UI."
[config.messaging]
description = "Recieve messages about your charging sessions."
title = "Notifications"
[config.meter]
cancel = "Cancel"
delete = "Delete"
@ -72,6 +140,34 @@ template = "Manufacturer"
titleChoice = "What Do You Want To Add?"
validateSave = "Validate & save"
[config.modbusproxy]
description = "Allow multiple clients to access a single Modbus device."
title = "Modbus Proxy"
[config.mqtt]
authentication = "Authentication"
description = "Connect to an MQTT broker to exchange data with other systems on your network."
descriptionClientId = "Author of the messages. If empty `evcc-[rand]` is used."
descriptionTopic = "Leave empty to disable publishing."
labelBroker = "Broker"
labelCheckInsecure = "Allow self-signed certificates"
labelClientId = "Client ID"
labelInsecure = "Certificate validation"
labelPassword = "Password"
labelTopic = "Topic"
labelUser = "Username"
publishing = "Publishing"
title = "MQTT"
[config.network]
descriptionHost = "Use .local suffix to enable mDNS. Relevant for discovery of the mobile app and some OCPP chargers."
descriptionPort = "Port for the web interface and API. You'll need to update your browser URL if you change this."
descriptionSchema = "Only affects how URLs are generated. Selecting HTTPS will not enable encryption."
labelHost = "Hostname"
labelPort = "Port"
labelSchema = "Schema"
title = "Network"
[config.options]
[config.options.endianness]
@ -88,7 +184,21 @@ titleEdit = "Edit Solar Meter"
[config.section]
general = "General"
grid = "Grid"
integrations = "Integrations"
loadpoints = "Charging points"
meter = "Solar & Battery"
system = "System"
vehicles = "Vehicles"
[config.sponsor]
addToken = "Enter sponsor token"
changeToken = "Change sponsor 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"
[config.system]
logs = "Logs"
@ -98,6 +208,10 @@ restartRequiredMessage = "Configuration changed."
restartingDescription = "Please wait…"
restartingMessage = "Restarting evcc."
[config.tariffs]
description = "Define your energy tariffs to calculate the costs of your charging sessions."
title = "Tariffs"
[config.title]
description = "Displayed on main screen and browser tab."
label = "Title"
@ -428,7 +542,9 @@ modalTitle = "Notifications"
[offline]
message = "Not connected to a server."
reload = "Reload?"
restart = "Restart"
restartNeeded = "Required to apply changes."
restarting = "Server will be back in a moment."
[passwordModal]
description = "Set a password to protect the configuration settings. Using the main screen is still possible without login."
@ -514,7 +630,7 @@ auto = "Automatic"
label = "Language"
[settings.sponsorToken]
expires = "You sponsor token expires {inXDays}. {getNewToken} and update your configuration file."
expires = "You sponsor token expires {inXDays}. {getNewToken} and update it here."
getNew = "Grab a fresh one"
hint = "Note: We will automate this in the future."

Some files were not shown because too many files have changed in this diff Show more